Merge pull request #526 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Refactor: stagger tasks creation for playlist processing
This commit is contained in:
commit
18673f0a55
31 changed files with 330 additions and 567 deletions
4
.github/workflows/build-pr.yml
vendored
4
.github/workflows/build-pr.yml
vendored
|
|
@ -75,6 +75,10 @@ jobs:
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run typecheck
|
run: pnpm run typecheck
|
||||||
|
|
||||||
|
- name: Run frontend tsc
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm run lint:tsc
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run test:ci
|
run: pnpm run test:ci
|
||||||
|
|
|
||||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -109,6 +109,10 @@ jobs:
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run typecheck
|
run: pnpm run typecheck
|
||||||
|
|
||||||
|
- name: Run frontend tsc
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm run lint:tsc
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run test:ci
|
run: pnpm run test:ci
|
||||||
|
|
|
||||||
|
|
@ -322,8 +322,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
playlistCount = entry.get("playlist_count")
|
playlistCount = entry.get("playlist_count")
|
||||||
playlistCount: int = int(playlistCount) if playlistCount else len(entries)
|
playlistCount: int = int(playlistCount) if playlistCount else len(entries)
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
playlist_keys: dict[str, Any] = {
|
playlist_keys: dict[str, Any] = {
|
||||||
"playlist_count": playlistCount,
|
"playlist_count": playlistCount,
|
||||||
"playlist": entry.get("title") or entry.get("id"),
|
"playlist": entry.get("title") or entry.get("id"),
|
||||||
|
|
@ -339,18 +337,16 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
}
|
}
|
||||||
|
|
||||||
async def playlist_processor(i: int, etr: dict):
|
async def playlist_processor(i: int, etr: dict):
|
||||||
|
acquired = False
|
||||||
try:
|
try:
|
||||||
item_name: str = (
|
item_name: str = (
|
||||||
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
|
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
|
||||||
)
|
)
|
||||||
LOG.debug(f"Waiting to acquire lock for {item_name}")
|
LOG.debug(f"Waiting to acquire lock for {item_name}")
|
||||||
await self.processors.acquire()
|
await self.processors.acquire()
|
||||||
|
acquired = True
|
||||||
LOG.debug(f"Acquired lock for {item_name}")
|
LOG.debug(f"Acquired lock for {item_name}")
|
||||||
|
|
||||||
if self.is_paused():
|
|
||||||
LOG.warning(f"Download is paused. Skipping processing of '{item_name}'.")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
LOG.info(f"Processing '{item_name}'.")
|
LOG.info(f"Processing '{item_name}'.")
|
||||||
|
|
||||||
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
|
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
|
||||||
|
|
@ -382,26 +378,44 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
return await self.add(item=newItem, already=already)
|
return await self.add(item=newItem, already=already)
|
||||||
finally:
|
finally:
|
||||||
self.processors.release()
|
if acquired:
|
||||||
|
self.processors.release()
|
||||||
|
|
||||||
max_downloads: int = -1
|
max_downloads: int = -1
|
||||||
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
|
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
|
||||||
if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
|
if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
|
||||||
max_downloads: int = ytdlp_opts.get("max_downloads")
|
max_downloads: int = ytdlp_opts.get("max_downloads")
|
||||||
|
|
||||||
tasks: list[asyncio.Task] = []
|
batch_size: int = max(50, int(self.config.playlist_items_concurrency) * 10)
|
||||||
|
|
||||||
|
async def run_batch(batch: list[tuple[int, dict]]) -> list[dict]:
|
||||||
|
tasks: list[asyncio.Task] = []
|
||||||
|
for i, etr in batch:
|
||||||
|
task = asyncio.create_task(
|
||||||
|
playlist_processor(i, etr),
|
||||||
|
name=f"playlist_processor_{etr.get('id')}_{i}",
|
||||||
|
)
|
||||||
|
task.add_done_callback(self._handle_task_exception)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
batch_results: list[dict] = await asyncio.gather(*tasks)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return batch_results
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
batch: list[tuple[int, dict]] = []
|
||||||
|
|
||||||
for i, etr in enumerate(entries, start=1):
|
for i, etr in enumerate(entries, start=1):
|
||||||
if max_downloads > 0 and i > max_downloads:
|
if max_downloads > 0 and i > max_downloads:
|
||||||
break
|
break
|
||||||
|
|
||||||
task = asyncio.create_task(
|
batch.append((i, etr))
|
||||||
playlist_processor(i, etr),
|
if len(batch) >= batch_size:
|
||||||
name=f"playlist_processor_{etr.get('id')}_{i}",
|
results.extend(await run_batch(batch))
|
||||||
)
|
batch.clear()
|
||||||
task.add_done_callback(self._handle_task_exception)
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
results: list[dict] = await asyncio.gather(*tasks)
|
if batch:
|
||||||
|
results.extend(await run_batch(batch))
|
||||||
|
|
||||||
log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
|
log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
|
||||||
if max_downloads > 0 and len(entries) > max_downloads:
|
if max_downloads > 0 and len(entries) > max_downloads:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from datetime import UTC, datetime, timedelta
|
||||||
from functools import lru_cache, wraps
|
from functools import lru_cache, wraps
|
||||||
from http.cookiejar import MozillaCookieJar
|
from http.cookiejar import MozillaCookieJar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from yt_dlp.utils import age_restricted
|
from yt_dlp.utils import age_restricted
|
||||||
|
|
@ -85,9 +85,6 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
||||||
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||||
"Regex to match ISO 8601 datetime strings."
|
"Regex to match ISO 8601 datetime strings."
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
"Generic type variable."
|
|
||||||
|
|
||||||
|
|
||||||
class StreamingError(Exception):
|
class StreamingError(Exception):
|
||||||
"""Raised when an error occurs during streaming."""
|
"""Raised when an error occurs during streaming."""
|
||||||
|
|
@ -1543,7 +1540,7 @@ def delete_dir(dir: Path) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def init_class(cls: type[T], data: dict, _fields=None) -> T:
|
def init_class[T](cls: type[T], data: dict, _fields=None) -> T:
|
||||||
"""
|
"""
|
||||||
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.
|
Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ class FFProbeResult:
|
||||||
|
|
||||||
|
|
||||||
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
||||||
async def ffprobe(file: Path|str) -> FFProbeResult:
|
async def ffprobe(file: Path | str) -> FFProbeResult:
|
||||||
"""
|
"""
|
||||||
Run ffprobe on a file and return the parsed data as a dictionary.
|
Run ffprobe on a file and return the parsed data as a dictionary.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
# flake8: noqa: S608
|
# flake8: noqa: S608
|
||||||
|
"""
|
||||||
|
This module is a fork of the Caribou library
|
||||||
|
(https://pypi.org/project/caribou/) modified to work for our specific use case.
|
||||||
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
import glob
|
import glob
|
||||||
|
|
@ -18,6 +23,7 @@ UTC_LENGTH = 14
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
class Error(Exception):
|
class Error(Exception):
|
||||||
"""Base class for all Caribou errors."""
|
"""Base class for all Caribou errors."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,4 +272,3 @@ def find_all(items: list[dict], **conditions) -> list[dict]:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return filter_items(items, **conditions)
|
return filter_items(items, **conditions)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
CACHE: Cache = Cache()
|
CACHE: Cache = Cache()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class MatchRule:
|
class MatchRule:
|
||||||
"""Represents a single URL matcher compiled to regex."""
|
"""Represents a single URL matcher compiled to regex."""
|
||||||
|
|
@ -720,12 +721,16 @@ class GenericTaskHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
LOG.error(f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping.")
|
LOG.error(
|
||||||
|
f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
|
||||||
|
)
|
||||||
CACHE.set(cache_key, None)
|
CACHE.set(cache_key, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not info.get("id") or not info.get("extractor_key"):
|
if not info.get("id") or not info.get("extractor_key"):
|
||||||
LOG.error(f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping.")
|
LOG.error(
|
||||||
|
f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
|
||||||
|
)
|
||||||
CACHE.set(cache_key, None)
|
CACHE.set(cache_key, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class TwitchHandler(BaseHandler):
|
||||||
archive_id: str = entry.get("archive_id")
|
archive_id: str = entry.get("archive_id")
|
||||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
|
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
|
||||||
|
|
||||||
return TaskResult( items=task_items, metadata={ "feed_url": feed_url, "has_entries": has_items } )
|
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse(url: str) -> str | None:
|
def parse(url: str) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Migration Name: add_status_index
|
||||||
Migration Version: 20251116173731
|
Migration Version: 20251116173731
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def upgrade(connection):
|
async def upgrade(connection):
|
||||||
"""
|
"""
|
||||||
Add index on json_extract(data, '$.status') for better query performance.
|
Add index on json_extract(data, '$.status') for better query performance.
|
||||||
|
|
@ -17,6 +18,7 @@ async def upgrade(connection):
|
||||||
"""
|
"""
|
||||||
await connection.execute(sql)
|
await connection.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
async def downgrade(connection):
|
async def downgrade(connection):
|
||||||
"""
|
"""
|
||||||
Remove the status index.
|
Remove the status index.
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,7 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
notify.emit(
|
notify.emit(Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid)
|
||||||
Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@route(RouteType.SOCKET, "item_cancel", "item_cancel")
|
@route(RouteType.SOCKET, "item_cancel", "item_cancel")
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ class TestCache:
|
||||||
|
|
||||||
def test_async_set(self):
|
def test_async_set(self):
|
||||||
"""Test async set method using asyncio.run."""
|
"""Test async set method using asyncio.run."""
|
||||||
|
|
||||||
async def async_test():
|
async def async_test():
|
||||||
await self.cache.aset("async_key", "async_value")
|
await self.cache.aset("async_key", "async_value")
|
||||||
assert self.cache.get("async_key") == "async_value"
|
assert self.cache.get("async_key") == "async_value"
|
||||||
|
|
@ -180,6 +181,7 @@ class TestCache:
|
||||||
|
|
||||||
def test_async_set_with_ttl(self):
|
def test_async_set_with_ttl(self):
|
||||||
"""Test async set with TTL using asyncio.run."""
|
"""Test async set with TTL using asyncio.run."""
|
||||||
|
|
||||||
async def async_test():
|
async def async_test():
|
||||||
await self.cache.aset("async_temp", "async_value", ttl=0.1)
|
await self.cache.aset("async_temp", "async_value", ttl=0.1)
|
||||||
assert self.cache.get("async_temp") == "async_value"
|
assert self.cache.get("async_temp") == "async_value"
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,7 @@ class TestCondition:
|
||||||
extras = {"key": "value", "number": 42}
|
extras = {"key": "value", "number": 42}
|
||||||
|
|
||||||
condition = Condition(
|
condition = Condition(
|
||||||
id=test_id,
|
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras
|
||||||
name="full_test",
|
|
||||||
filter="uploader = 'test'",
|
|
||||||
cli="--format best",
|
|
||||||
extras=extras
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert condition.id == test_id
|
assert condition.id == test_id
|
||||||
|
|
@ -64,10 +60,7 @@ class TestCondition:
|
||||||
def test_condition_serialize(self):
|
def test_condition_serialize(self):
|
||||||
"""Test condition serialization to dict."""
|
"""Test condition serialization to dict."""
|
||||||
condition = Condition(
|
condition = Condition(
|
||||||
name="serialize_test",
|
name="serialize_test", filter="title ~= 'test'", cli="--audio-quality 0", extras={"tag": "music"}
|
||||||
filter="title ~= 'test'",
|
|
||||||
cli="--audio-quality 0",
|
|
||||||
extras={"tag": "music"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized = condition.serialize()
|
serialized = condition.serialize()
|
||||||
|
|
@ -93,11 +86,7 @@ class TestCondition:
|
||||||
|
|
||||||
def test_condition_get_method(self):
|
def test_condition_get_method(self):
|
||||||
"""Test condition get method for accessing fields."""
|
"""Test condition get method for accessing fields."""
|
||||||
condition = Condition(
|
condition = Condition(name="get_test", filter="view_count > 1000", extras={"category": "popular"})
|
||||||
name="get_test",
|
|
||||||
filter="view_count > 1000",
|
|
||||||
extras={"category": "popular"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert condition.get("name") == "get_test"
|
assert condition.get("name") == "get_test"
|
||||||
assert condition.get("filter") == "view_count > 1000"
|
assert condition.get("filter") == "view_count > 1000"
|
||||||
|
|
@ -165,7 +154,7 @@ class TestConditions:
|
||||||
# Add some test conditions
|
# Add some test conditions
|
||||||
conditions._items = [
|
conditions._items = [
|
||||||
Condition(name="test1", filter="duration > 60"),
|
Condition(name="test1", filter="duration > 60"),
|
||||||
Condition(name="test2", filter="uploader = 'test'")
|
Condition(name="test2", filter="uploader = 'test'"),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = conditions.clear()
|
result = conditions.clear()
|
||||||
|
|
@ -219,15 +208,15 @@ class TestConditions:
|
||||||
"name": "short_videos",
|
"name": "short_videos",
|
||||||
"filter": "duration < 300",
|
"filter": "duration < 300",
|
||||||
"cli": "--format worst",
|
"cli": "--format worst",
|
||||||
"extras": {"category": "short"}
|
"extras": {"category": "short"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"name": "music_videos",
|
"name": "music_videos",
|
||||||
"filter": "title ~= 'music'",
|
"filter": "title ~= 'music'",
|
||||||
"cli": "--audio-quality 0",
|
"cli": "--audio-quality 0",
|
||||||
"extras": {"type": "audio"}
|
"extras": {"type": "audio"},
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
file_path.write_text(json.dumps(test_data, indent=4))
|
file_path.write_text(json.dumps(test_data, indent=4))
|
||||||
|
|
@ -254,12 +243,7 @@ class TestConditions:
|
||||||
file_path = Path(temp_dir) / "no_id_conditions.json"
|
file_path = Path(temp_dir) / "no_id_conditions.json"
|
||||||
|
|
||||||
# Create test data without ID
|
# Create test data without ID
|
||||||
test_data = [
|
test_data = [{"name": "no_id_test", "filter": "duration > 120"}]
|
||||||
{
|
|
||||||
"name": "no_id_test",
|
|
||||||
"filter": "duration > 120"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
file_path.write_text(json.dumps(test_data))
|
file_path.write_text(json.dumps(test_data))
|
||||||
|
|
||||||
|
|
@ -280,13 +264,7 @@ class TestConditions:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
file_path = Path(temp_dir) / "no_extras_conditions.json"
|
file_path = Path(temp_dir) / "no_extras_conditions.json"
|
||||||
|
|
||||||
test_data = [
|
test_data = [{"id": str(uuid.uuid4()), "name": "no_extras_test", "filter": "uploader = 'test'"}]
|
||||||
{
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"name": "no_extras_test",
|
|
||||||
"filter": "uploader = 'test'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
file_path.write_text(json.dumps(test_data))
|
file_path.write_text(json.dumps(test_data))
|
||||||
|
|
||||||
|
|
@ -321,7 +299,7 @@ class TestConditions:
|
||||||
# Missing required fields
|
# Missing required fields
|
||||||
test_data = [
|
test_data = [
|
||||||
{"id": "valid", "name": "valid", "filter": "duration > 60"},
|
{"id": "valid", "name": "valid", "filter": "duration > 60"},
|
||||||
{"invalid": "data"} # Missing required fields
|
{"invalid": "data"}, # Missing required fields
|
||||||
]
|
]
|
||||||
|
|
||||||
file_path.write_text(json.dumps(test_data))
|
file_path.write_text(json.dumps(test_data))
|
||||||
|
|
@ -345,7 +323,7 @@ class TestConditions:
|
||||||
"name": "valid_test",
|
"name": "valid_test",
|
||||||
"filter": "duration > 60",
|
"filter": "duration > 60",
|
||||||
"cli": "--format best",
|
"cli": "--format best",
|
||||||
"extras": {"key": "value"}
|
"extras": {"key": "value"},
|
||||||
}
|
}
|
||||||
|
|
||||||
result = conditions.validate(valid_condition)
|
result = conditions.validate(valid_condition)
|
||||||
|
|
@ -357,10 +335,7 @@ class TestConditions:
|
||||||
file_path = Path(temp_dir) / "validate_obj_test.json"
|
file_path = Path(temp_dir) / "validate_obj_test.json"
|
||||||
conditions = Conditions(file=file_path)
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
valid_condition = Condition(
|
valid_condition = Condition(name="valid_obj_test", filter="uploader = 'test'")
|
||||||
name="valid_obj_test",
|
|
||||||
filter="uploader = 'test'"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = conditions.validate(valid_condition)
|
result = conditions.validate(valid_condition)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
@ -371,10 +346,7 @@ class TestConditions:
|
||||||
file_path = Path(temp_dir) / "no_id_validate.json"
|
file_path = Path(temp_dir) / "no_id_validate.json"
|
||||||
conditions = Conditions(file=file_path)
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
invalid_condition = {
|
invalid_condition = {"name": "no_id_test", "filter": "duration > 60"}
|
||||||
"name": "no_id_test",
|
|
||||||
"filter": "duration > 60"
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="No id found"):
|
with pytest.raises(ValueError, match="No id found"):
|
||||||
conditions.validate(invalid_condition)
|
conditions.validate(invalid_condition)
|
||||||
|
|
@ -385,10 +357,7 @@ class TestConditions:
|
||||||
file_path = Path(temp_dir) / "no_name_validate.json"
|
file_path = Path(temp_dir) / "no_name_validate.json"
|
||||||
conditions = Conditions(file=file_path)
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
invalid_condition = {
|
invalid_condition = {"id": str(uuid.uuid4()), "filter": "duration > 60"}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"filter": "duration > 60"
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="No name found"):
|
with pytest.raises(ValueError, match="No name found"):
|
||||||
conditions.validate(invalid_condition)
|
conditions.validate(invalid_condition)
|
||||||
|
|
@ -399,10 +368,7 @@ class TestConditions:
|
||||||
file_path = Path(temp_dir) / "no_filter_validate.json"
|
file_path = Path(temp_dir) / "no_filter_validate.json"
|
||||||
conditions = Conditions(file=file_path)
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
invalid_condition = {
|
invalid_condition = {"id": str(uuid.uuid4()), "name": "no_filter_test"}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"name": "no_filter_test"
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="No filter found"):
|
with pytest.raises(ValueError, match="No filter found"):
|
||||||
conditions.validate(invalid_condition)
|
conditions.validate(invalid_condition)
|
||||||
|
|
@ -419,7 +385,7 @@ class TestConditions:
|
||||||
"name": "invalid_filter_test",
|
"name": "invalid_filter_test",
|
||||||
"filter": "duration > & < 60", # Invalid syntax with consecutive operators
|
"filter": "duration > & < 60", # Invalid syntax with consecutive operators
|
||||||
"cli": "",
|
"cli": "",
|
||||||
"extras": {}
|
"extras": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid filter"):
|
with pytest.raises(ValueError, match="Invalid filter"):
|
||||||
|
|
@ -435,7 +401,7 @@ class TestConditions:
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"name": "invalid_cli_test",
|
"name": "invalid_cli_test",
|
||||||
"filter": "duration > 60",
|
"filter": "duration > 60",
|
||||||
"cli": "--invalid-option-that-does-not-exist"
|
"cli": "--invalid-option-that-does-not-exist",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Invalid command options"):
|
with pytest.raises(ValueError, match="Invalid command options"):
|
||||||
|
|
@ -451,7 +417,7 @@ class TestConditions:
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"name": "invalid_extras_test",
|
"name": "invalid_extras_test",
|
||||||
"filter": "duration > 60",
|
"filter": "duration > 60",
|
||||||
"extras": "not a dict"
|
"extras": "not a dict",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Extras must be a dictionary"):
|
with pytest.raises(ValueError, match="Extras must be a dictionary"):
|
||||||
|
|
@ -474,7 +440,7 @@ class TestConditions:
|
||||||
|
|
||||||
test_conditions = [
|
test_conditions = [
|
||||||
Condition(name="save_test1", filter="duration > 60"),
|
Condition(name="save_test1", filter="duration > 60"),
|
||||||
Condition(name="save_test2", filter="uploader = 'test'")
|
Condition(name="save_test2", filter="uploader = 'test'"),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = conditions.save(test_conditions)
|
result = conditions.save(test_conditions)
|
||||||
|
|
@ -495,13 +461,7 @@ class TestConditions:
|
||||||
conditions = Conditions(file=file_path)
|
conditions = Conditions(file=file_path)
|
||||||
|
|
||||||
test_conditions = [
|
test_conditions = [
|
||||||
{
|
{"id": str(uuid.uuid4()), "name": "dict_test", "filter": "duration < 300", "cli": "", "extras": {}}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"name": "dict_test",
|
|
||||||
"filter": "duration < 300",
|
|
||||||
"cli": "",
|
|
||||||
"extras": {}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
conditions.save(test_conditions)
|
conditions.save(test_conditions)
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class TestDownloadHooks:
|
||||||
return Cfg
|
return Cfg
|
||||||
|
|
||||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||||
|
|
||||||
# EventBus.get_instance is used during __init__ and start, we don't hit start here
|
# EventBus.get_instance is used during __init__ and start, we don't hit start here
|
||||||
class EB:
|
class EB:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -113,7 +114,17 @@ class TestDownloadHooks:
|
||||||
assert ev["action"] == "progress"
|
assert ev["action"] == "progress"
|
||||||
# ensure only whitelisted keys included
|
# ensure only whitelisted keys included
|
||||||
assert "other" not in ev
|
assert "other" not in ev
|
||||||
for k in ("tmpfilename", "filename", "status", "msg", "total_bytes", "total_bytes_estimate", "downloaded_bytes", "speed", "eta"):
|
for k in (
|
||||||
|
"tmpfilename",
|
||||||
|
"filename",
|
||||||
|
"status",
|
||||||
|
"msg",
|
||||||
|
"total_bytes",
|
||||||
|
"total_bytes_estimate",
|
||||||
|
"downloaded_bytes",
|
||||||
|
"speed",
|
||||||
|
"eta",
|
||||||
|
):
|
||||||
assert k in ev
|
assert k in ev
|
||||||
|
|
||||||
def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None:
|
def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class TestEncoder:
|
||||||
|
|
||||||
def test_object_with_serialize_method(self):
|
def test_object_with_serialize_method(self):
|
||||||
"""Test that objects with serialize method use it."""
|
"""Test that objects with serialize method use it."""
|
||||||
|
|
||||||
class CustomObject:
|
class CustomObject:
|
||||||
def serialize(self):
|
def serialize(self):
|
||||||
return {"custom": "data", "type": "test"}
|
return {"custom": "data", "type": "test"}
|
||||||
|
|
@ -64,6 +65,7 @@ class TestEncoder:
|
||||||
|
|
||||||
def test_object_with_dict_fallback(self):
|
def test_object_with_dict_fallback(self):
|
||||||
"""Test that objects without serialize method fall back to __dict__."""
|
"""Test that objects without serialize method fall back to __dict__."""
|
||||||
|
|
||||||
class SimpleObject:
|
class SimpleObject:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = "test"
|
self.name = "test"
|
||||||
|
|
@ -81,12 +83,7 @@ class TestEncoder:
|
||||||
|
|
||||||
def test_json_dumps_integration(self):
|
def test_json_dumps_integration(self):
|
||||||
"""Test full JSON serialization with various types."""
|
"""Test full JSON serialization with various types."""
|
||||||
data = {
|
data = {"path": Path("/tmp/test.txt"), "date": date(2024, 1, 1), "number": 42, "string": "test"}
|
||||||
"path": Path("/tmp/test.txt"),
|
|
||||||
"date": date(2024, 1, 1),
|
|
||||||
"number": 42,
|
|
||||||
"string": "test"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = json.dumps(data, cls=Encoder)
|
result = json.dumps(data, cls=Encoder)
|
||||||
parsed = json.loads(result)
|
parsed = json.loads(result)
|
||||||
|
|
@ -98,15 +95,13 @@ class TestEncoder:
|
||||||
|
|
||||||
def test_json_dumps_with_custom_object(self):
|
def test_json_dumps_with_custom_object(self):
|
||||||
"""Test JSON serialization with custom objects."""
|
"""Test JSON serialization with custom objects."""
|
||||||
|
|
||||||
class TestObject:
|
class TestObject:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = "test"
|
self.name = "test"
|
||||||
self.items = [1, 2, 3]
|
self.items = [1, 2, 3]
|
||||||
|
|
||||||
data = {
|
data = {"object": TestObject(), "regular": "data"}
|
||||||
"object": TestObject(),
|
|
||||||
"regular": "data"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = json.dumps(data, cls=Encoder)
|
result = json.dumps(data, cls=Encoder)
|
||||||
parsed = json.loads(result)
|
parsed = json.loads(result)
|
||||||
|
|
@ -117,6 +112,7 @@ class TestEncoder:
|
||||||
|
|
||||||
def test_nested_serialization(self):
|
def test_nested_serialization(self):
|
||||||
"""Test serialization of nested structures with various types."""
|
"""Test serialization of nested structures with various types."""
|
||||||
|
|
||||||
class CustomObj:
|
class CustomObj:
|
||||||
def serialize(self):
|
def serialize(self):
|
||||||
return {"serialized": True}
|
return {"serialized": True}
|
||||||
|
|
@ -125,10 +121,7 @@ class TestEncoder:
|
||||||
"paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")],
|
"paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")],
|
||||||
"dates": [date(2024, 1, 1), date(2024, 12, 31)],
|
"dates": [date(2024, 1, 1), date(2024, 12, 31)],
|
||||||
"custom": CustomObj(),
|
"custom": CustomObj(),
|
||||||
"nested": {
|
"nested": {"path": Path("/nested/path"), "date": date(2024, 6, 15)},
|
||||||
"path": Path("/nested/path"),
|
|
||||||
"date": date(2024, 6, 15)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = json.dumps(data, cls=Encoder)
|
result = json.dumps(data, cls=Encoder)
|
||||||
|
|
@ -149,6 +142,7 @@ class TestEncoder:
|
||||||
|
|
||||||
# Mock isinstance to return True for DateRange
|
# Mock isinstance to return True for DateRange
|
||||||
import builtins
|
import builtins
|
||||||
|
|
||||||
original_isinstance = builtins.isinstance
|
original_isinstance = builtins.isinstance
|
||||||
|
|
||||||
def mock_isinstance(obj, cls):
|
def mock_isinstance(obj, cls):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class TestFFProbe:
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
"""Clean up test files."""
|
"""Clean up test files."""
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -64,6 +65,7 @@ class TestFFProbe:
|
||||||
# Mock subprocess to avoid actual ffprobe execution
|
# Mock subprocess to avoid actual ffprobe execution
|
||||||
call_count = 0
|
call_count = 0
|
||||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||||
|
|
||||||
def create_mock_process(*_args, **_kwargs):
|
def create_mock_process(*_args, **_kwargs):
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
|
|
@ -130,20 +132,11 @@ class TestFFProbe:
|
||||||
assert result.metadata == {}
|
assert result.metadata == {}
|
||||||
|
|
||||||
# Test adding streams
|
# Test adding streams
|
||||||
video_stream = FFStream({
|
video_stream = FFStream(
|
||||||
"index": 0,
|
{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080}
|
||||||
"codec_type": "video",
|
)
|
||||||
"codec_name": "h264",
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1080
|
|
||||||
})
|
|
||||||
|
|
||||||
audio_stream = FFStream({
|
audio_stream = FFStream({"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2})
|
||||||
"index": 1,
|
|
||||||
"codec_type": "audio",
|
|
||||||
"codec_name": "aac",
|
|
||||||
"channels": 2
|
|
||||||
})
|
|
||||||
|
|
||||||
result.video.append(video_stream)
|
result.video.append(video_stream)
|
||||||
result.audio.append(audio_stream)
|
result.audio.append(audio_stream)
|
||||||
|
|
@ -158,13 +151,9 @@ class TestFFProbe:
|
||||||
from app.library.ffprobe import FFStream
|
from app.library.ffprobe import FFStream
|
||||||
|
|
||||||
# Test video stream
|
# Test video stream
|
||||||
video_stream = FFStream({
|
video_stream = FFStream(
|
||||||
"codec_type": "video",
|
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, "duration": "10.5"}
|
||||||
"codec_name": "h264",
|
)
|
||||||
"width": 1920,
|
|
||||||
"height": 1080,
|
|
||||||
"duration": "10.5"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert video_stream.is_video()
|
assert video_stream.is_video()
|
||||||
assert not video_stream.is_audio()
|
assert not video_stream.is_audio()
|
||||||
|
|
@ -172,12 +161,7 @@ class TestFFProbe:
|
||||||
assert not video_stream.is_attachment()
|
assert not video_stream.is_attachment()
|
||||||
|
|
||||||
# Test audio stream
|
# Test audio stream
|
||||||
audio_stream = FFStream({
|
audio_stream = FFStream({"codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "44100"})
|
||||||
"codec_type": "audio",
|
|
||||||
"codec_name": "aac",
|
|
||||||
"channels": 2,
|
|
||||||
"sample_rate": "44100"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert not audio_stream.is_video()
|
assert not audio_stream.is_video()
|
||||||
assert audio_stream.is_audio()
|
assert audio_stream.is_audio()
|
||||||
|
|
@ -185,10 +169,7 @@ class TestFFProbe:
|
||||||
assert not audio_stream.is_attachment()
|
assert not audio_stream.is_attachment()
|
||||||
|
|
||||||
# Test subtitle stream
|
# Test subtitle stream
|
||||||
subtitle_stream = FFStream({
|
subtitle_stream = FFStream({"codec_type": "subtitle", "codec_name": "subrip"})
|
||||||
"codec_type": "subtitle",
|
|
||||||
"codec_name": "subrip"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert not subtitle_stream.is_video()
|
assert not subtitle_stream.is_video()
|
||||||
assert not subtitle_stream.is_audio()
|
assert not subtitle_stream.is_audio()
|
||||||
|
|
@ -196,10 +177,7 @@ class TestFFProbe:
|
||||||
assert not subtitle_stream.is_attachment()
|
assert not subtitle_stream.is_attachment()
|
||||||
|
|
||||||
# Test attachment stream
|
# Test attachment stream
|
||||||
attachment_stream = FFStream({
|
attachment_stream = FFStream({"codec_type": "attachment", "codec_name": "ttf"})
|
||||||
"codec_type": "attachment",
|
|
||||||
"codec_name": "ttf"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert not attachment_stream.is_video()
|
assert not attachment_stream.is_video()
|
||||||
assert not attachment_stream.is_audio()
|
assert not attachment_stream.is_audio()
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,9 @@ class TestItemDTO:
|
||||||
}
|
}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")) as mock_get_file,
|
patch(
|
||||||
|
"app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")
|
||||||
|
) as mock_get_file,
|
||||||
patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
|
patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
|
||||||
):
|
):
|
||||||
result = dto.get_file_sidecar()
|
result = dto.get_file_sidecar()
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,7 @@ class TestTargetRequest:
|
||||||
|
|
||||||
def test_target_request_creation_defaults(self):
|
def test_target_request_creation_defaults(self):
|
||||||
"""Test creating a TargetRequest with defaults."""
|
"""Test creating a TargetRequest with defaults."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert request.type == "json"
|
assert request.type == "json"
|
||||||
assert request.method == "POST"
|
assert request.method == "POST"
|
||||||
|
|
@ -79,14 +75,10 @@ class TestTargetRequest:
|
||||||
"""Test creating a TargetRequest with headers."""
|
"""Test creating a TargetRequest with headers."""
|
||||||
headers = [
|
headers = [
|
||||||
TargetRequestHeader(key="Authorization", value="Bearer token"),
|
TargetRequestHeader(key="Authorization", value="Bearer token"),
|
||||||
TargetRequestHeader(key="Content-Type", value="application/json")
|
TargetRequestHeader(key="Content-Type", value="application/json"),
|
||||||
]
|
]
|
||||||
request = TargetRequest(
|
request = TargetRequest(
|
||||||
type="json",
|
type="json", method="POST", url="https://example.com/webhook", headers=headers, data_key="payload"
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook",
|
|
||||||
headers=headers,
|
|
||||||
data_key="payload"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(request.headers) == 2
|
assert len(request.headers) == 2
|
||||||
|
|
@ -97,11 +89,7 @@ class TestTargetRequest:
|
||||||
"""Test serializing a TargetRequest."""
|
"""Test serializing a TargetRequest."""
|
||||||
headers = [TargetRequestHeader(key="X-Token", value="abc123")]
|
headers = [TargetRequestHeader(key="X-Token", value="abc123")]
|
||||||
request = TargetRequest(
|
request = TargetRequest(
|
||||||
type="json",
|
type="json", method="PUT", url="https://api.example.com/notify", headers=headers, data_key="content"
|
||||||
method="PUT",
|
|
||||||
url="https://api.example.com/notify",
|
|
||||||
headers=headers,
|
|
||||||
data_key="content"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized = request.serialize()
|
serialized = request.serialize()
|
||||||
|
|
@ -110,17 +98,13 @@ class TestTargetRequest:
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"url": "https://api.example.com/notify",
|
"url": "https://api.example.com/notify",
|
||||||
"data_key": "content",
|
"data_key": "content",
|
||||||
"headers": [{"key": "X-Token", "value": "abc123"}]
|
"headers": [{"key": "X-Token", "value": "abc123"}],
|
||||||
}
|
}
|
||||||
assert serialized == expected
|
assert serialized == expected
|
||||||
|
|
||||||
def test_target_request_json(self):
|
def test_target_request_json(self):
|
||||||
"""Test JSON serialization of TargetRequest."""
|
"""Test JSON serialization of TargetRequest."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="form", method="POST", url="https://webhook.site/test")
|
||||||
type="form",
|
|
||||||
method="POST",
|
|
||||||
url="https://webhook.site/test"
|
|
||||||
)
|
|
||||||
json_str = request.json()
|
json_str = request.json()
|
||||||
|
|
||||||
parsed = json.loads(json_str)
|
parsed = json.loads(json_str)
|
||||||
|
|
@ -130,11 +114,7 @@ class TestTargetRequest:
|
||||||
|
|
||||||
def test_target_request_get(self):
|
def test_target_request_get(self):
|
||||||
"""Test get method of TargetRequest."""
|
"""Test get method of TargetRequest."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert request.get("type") == "json"
|
assert request.get("type") == "json"
|
||||||
assert request.get("method") == "POST"
|
assert request.get("method") == "POST"
|
||||||
|
|
@ -146,16 +126,8 @@ class TestTarget:
|
||||||
|
|
||||||
def test_target_creation_minimal(self):
|
def test_target_creation_minimal(self):
|
||||||
"""Test creating a Target with minimal required fields."""
|
"""Test creating a Target with minimal required fields."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Webhook", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Webhook",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
assert target.name == "Test Webhook"
|
assert target.name == "Test Webhook"
|
||||||
assert target.on == []
|
assert target.on == []
|
||||||
|
|
@ -165,17 +137,13 @@ class TestTarget:
|
||||||
def test_target_creation_full(self):
|
def test_target_creation_full(self):
|
||||||
"""Test creating a Target with all fields."""
|
"""Test creating a Target with all fields."""
|
||||||
target_id = str(uuid.uuid4())
|
target_id = str(uuid.uuid4())
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
target = Target(
|
||||||
id=target_id,
|
id=target_id,
|
||||||
name="Full Test Webhook",
|
name="Full Test Webhook",
|
||||||
on=["item_completed", "item_failed"],
|
on=["item_completed", "item_failed"],
|
||||||
presets=["default", "audio_only"],
|
presets=["default", "audio_only"],
|
||||||
request=request
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert target.id == target_id
|
assert target.id == target_id
|
||||||
|
|
@ -186,18 +154,8 @@ class TestTarget:
|
||||||
def test_target_serialize(self):
|
def test_target_serialize(self):
|
||||||
"""Test serializing a Target."""
|
"""Test serializing a Target."""
|
||||||
target_id = str(uuid.uuid4())
|
target_id = str(uuid.uuid4())
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=target_id, name="Test Target", on=["item_completed"], presets=["default"], request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=target_id,
|
|
||||||
name="Test Target",
|
|
||||||
on=["item_completed"],
|
|
||||||
presets=["default"],
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
serialized = target.serialize()
|
serialized = target.serialize()
|
||||||
assert serialized["id"] == target_id
|
assert serialized["id"] == target_id
|
||||||
|
|
@ -208,16 +166,8 @@ class TestTarget:
|
||||||
|
|
||||||
def test_target_json(self):
|
def test_target_json(self):
|
||||||
"""Test JSON serialization of Target."""
|
"""Test JSON serialization of Target."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="JSON Test", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="JSON Test",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
json_str = target.json()
|
json_str = target.json()
|
||||||
parsed = json.loads(json_str)
|
parsed = json.loads(json_str)
|
||||||
|
|
@ -225,16 +175,8 @@ class TestTarget:
|
||||||
|
|
||||||
def test_target_get(self):
|
def test_target_get(self):
|
||||||
"""Test get method of Target."""
|
"""Test get method of Target."""
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Get Test", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Get Test",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
assert target.get("name") == "Get Test"
|
assert target.get("name") == "Get Test"
|
||||||
assert target.get("nonexistent", "default") == "default"
|
assert target.get("nonexistent", "default") == "default"
|
||||||
|
|
@ -299,11 +241,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
def test_notification_singleton(self, mock_background_worker, mock_config):
|
def test_notification_singleton(self, mock_background_worker, mock_config):
|
||||||
"""Test that Notification follows singleton pattern."""
|
"""Test that Notification follows singleton pattern."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False,
|
|
||||||
config_path="/tmp",
|
|
||||||
app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_background_worker.get_instance.return_value = Mock()
|
mock_background_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
instance1 = Notification.get_instance()
|
instance1 = Notification.get_instance()
|
||||||
|
|
@ -316,11 +254,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
def test_notification_init_default_params(self, mock_background_worker, mock_config):
|
def test_notification_init_default_params(self, mock_background_worker, mock_config):
|
||||||
"""Test Notification initialization with default parameters."""
|
"""Test Notification initialization with default parameters."""
|
||||||
mock_config_instance = Mock(
|
mock_config_instance = Mock(debug=False, config_path="/tmp/test", app_version="1.0.0")
|
||||||
debug=False,
|
|
||||||
config_path="/tmp/test",
|
|
||||||
app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_config.get_instance.return_value = mock_config_instance
|
mock_config.get_instance.return_value = mock_config_instance
|
||||||
mock_background_worker.get_instance.return_value = Mock()
|
mock_background_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
|
|
@ -337,11 +271,7 @@ class TestNotification:
|
||||||
def test_notification_init_custom_params(self, mock_background_worker, mock_config):
|
def test_notification_init_custom_params(self, mock_background_worker, mock_config):
|
||||||
"""Test Notification initialization with custom parameters."""
|
"""Test Notification initialization with custom parameters."""
|
||||||
_ = mock_background_worker, mock_config # Suppress unused variable warnings
|
_ = mock_background_worker, mock_config # Suppress unused variable warnings
|
||||||
mock_config_instance = Mock(
|
mock_config_instance = Mock(debug=True, config_path="/custom/path", app_version="2.0.0")
|
||||||
debug=True,
|
|
||||||
config_path="/custom/path",
|
|
||||||
app_version="2.0.0"
|
|
||||||
)
|
|
||||||
mock_client = Mock(spec=httpx.AsyncClient)
|
mock_client = Mock(spec=httpx.AsyncClient)
|
||||||
mock_encoder = Mock(spec=Encoder)
|
mock_encoder = Mock(spec=Encoder)
|
||||||
mock_worker = Mock(spec=BackgroundWorker)
|
mock_worker = Mock(spec=BackgroundWorker)
|
||||||
|
|
@ -354,7 +284,7 @@ class TestNotification:
|
||||||
client=mock_client,
|
client=mock_client,
|
||||||
encoder=mock_encoder,
|
encoder=mock_encoder,
|
||||||
config=mock_config_instance,
|
config=mock_config_instance,
|
||||||
background_worker=mock_worker
|
background_worker=mock_worker,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert notification._debug is True
|
assert notification._debug is True
|
||||||
|
|
@ -365,12 +295,11 @@ class TestNotification:
|
||||||
|
|
||||||
def test_get_targets_empty(self):
|
def test_get_targets_empty(self):
|
||||||
"""Test get_targets when no targets are loaded."""
|
"""Test get_targets when no targets are loaded."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
@ -381,12 +310,11 @@ class TestNotification:
|
||||||
|
|
||||||
def test_clear_targets(self):
|
def test_clear_targets(self):
|
||||||
"""Test clearing notification targets."""
|
"""Test clearing notification targets."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
@ -405,25 +333,15 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
def test_save_targets(self, mock_worker, mock_config):
|
def test_save_targets(self, mock_worker, mock_config):
|
||||||
"""Test saving targets to file."""
|
"""Test saving targets to file."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
|
|
||||||
# Create a test target
|
# Create a test target
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
notification = Notification.get_instance(file=temp_path)
|
notification = Notification.get_instance(file=temp_path)
|
||||||
result = notification.save([target])
|
result = notification.save([target])
|
||||||
|
|
@ -445,9 +363,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
def test_load_targets_empty_file(self, mock_worker, mock_config):
|
def test_load_targets_empty_file(self, mock_worker, mock_config):
|
||||||
"""Test loading targets from empty file."""
|
"""Test loading targets from empty file."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
||||||
|
|
@ -468,9 +384,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.Presets")
|
@patch("app.library.Notifications.Presets")
|
||||||
def test_load_targets_valid_file(self, mock_presets, mock_worker, mock_config):
|
def test_load_targets_valid_file(self, mock_presets, mock_worker, mock_config):
|
||||||
"""Test loading targets from valid file."""
|
"""Test loading targets from valid file."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
# Mock preset with name attribute
|
# Mock preset with name attribute
|
||||||
|
|
@ -478,19 +392,21 @@ class TestNotification:
|
||||||
mock_preset.name = "default"
|
mock_preset.name = "default"
|
||||||
mock_presets.get_instance.return_value.get_all.return_value = [mock_preset]
|
mock_presets.get_instance.return_value.get_all.return_value = [mock_preset]
|
||||||
|
|
||||||
target_data = [{
|
target_data = [
|
||||||
"id": str(uuid.uuid4()),
|
{
|
||||||
"name": "Test Webhook",
|
"id": str(uuid.uuid4()),
|
||||||
"on": ["item_completed"],
|
"name": "Test Webhook",
|
||||||
"presets": ["default"],
|
"on": ["item_completed"],
|
||||||
"request": {
|
"presets": ["default"],
|
||||||
"type": "json",
|
"request": {
|
||||||
"method": "POST",
|
"type": "json",
|
||||||
"url": "https://example.com/webhook",
|
"method": "POST",
|
||||||
"data_key": "data",
|
"url": "https://example.com/webhook",
|
||||||
"headers": []
|
"data_key": "data",
|
||||||
|
"headers": [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}]
|
]
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
||||||
json.dump(target_data, temp_file)
|
json.dump(target_data, temp_file)
|
||||||
|
|
@ -508,12 +424,11 @@ class TestNotification:
|
||||||
|
|
||||||
def test_make_target(self):
|
def test_make_target(self):
|
||||||
"""Test make_target method."""
|
"""Test make_target method."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
@ -528,8 +443,8 @@ class TestNotification:
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"data_key": "payload",
|
"data_key": "payload",
|
||||||
"headers": [{"key": "Authorization", "value": "Bearer token"}]
|
"headers": [{"key": "Authorization", "value": "Bearer token"}],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
target = notification.make_target(target_dict)
|
target = notification.make_target(target_dict)
|
||||||
|
|
@ -548,14 +463,13 @@ class TestNotification:
|
||||||
target_dict = {
|
target_dict = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"name": "Valid Target",
|
"name": "Valid Target",
|
||||||
"request": {
|
"request": {"url": "https://example.com/webhook"},
|
||||||
"url": "https://example.com/webhook"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("app.library.Notifications.NotificationEvents.get_events") as mock_events, \
|
with (
|
||||||
patch("app.library.Notifications.Presets") as mock_presets:
|
patch("app.library.Notifications.NotificationEvents.get_events") as mock_events,
|
||||||
|
patch("app.library.Notifications.Presets") as mock_presets,
|
||||||
|
):
|
||||||
mock_events.return_value.values.return_value = ["item_completed"]
|
mock_events.return_value.values.return_value = ["item_completed"]
|
||||||
mock_presets.get_instance.return_value.get_all.return_value = []
|
mock_presets.get_instance.return_value.get_all.return_value = []
|
||||||
|
|
||||||
|
|
@ -564,12 +478,7 @@ class TestNotification:
|
||||||
|
|
||||||
def test_validate_target_missing_id(self):
|
def test_validate_target_missing_id(self):
|
||||||
"""Test validate method with missing ID."""
|
"""Test validate method with missing ID."""
|
||||||
target_dict = {
|
target_dict = {"name": "Missing ID Target", "request": {"url": "https://example.com/webhook"}}
|
||||||
"name": "Missing ID Target",
|
|
||||||
"request": {
|
|
||||||
"url": "https://example.com/webhook"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
|
||||||
Notification.validate(target_dict)
|
Notification.validate(target_dict)
|
||||||
|
|
@ -579,9 +488,7 @@ class TestNotification:
|
||||||
target_dict = {
|
target_dict = {
|
||||||
"id": "invalid-uuid",
|
"id": "invalid-uuid",
|
||||||
"name": "Invalid ID Target",
|
"name": "Invalid ID Target",
|
||||||
"request": {
|
"request": {"url": "https://example.com/webhook"},
|
||||||
"url": "https://example.com/webhook"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. No ID found\."):
|
||||||
|
|
@ -589,33 +496,21 @@ class TestNotification:
|
||||||
|
|
||||||
def test_validate_target_missing_name(self):
|
def test_validate_target_missing_name(self):
|
||||||
"""Test validate method with missing name."""
|
"""Test validate method with missing name."""
|
||||||
target_dict = {
|
target_dict = {"id": str(uuid.uuid4()), "request": {"url": "https://example.com/webhook"}}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"request": {
|
|
||||||
"url": "https://example.com/webhook"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. No name found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. No name found\."):
|
||||||
Notification.validate(target_dict)
|
Notification.validate(target_dict)
|
||||||
|
|
||||||
def test_validate_target_missing_request(self):
|
def test_validate_target_missing_request(self):
|
||||||
"""Test validate method with missing request."""
|
"""Test validate method with missing request."""
|
||||||
target_dict = {
|
target_dict = {"id": str(uuid.uuid4()), "name": "Missing Request Target"}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"name": "Missing Request Target"
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. No request details found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. No request details found\."):
|
||||||
Notification.validate(target_dict)
|
Notification.validate(target_dict)
|
||||||
|
|
||||||
def test_validate_target_missing_url(self):
|
def test_validate_target_missing_url(self):
|
||||||
"""Test validate method with missing URL."""
|
"""Test validate method with missing URL."""
|
||||||
target_dict = {
|
target_dict = {"id": str(uuid.uuid4()), "name": "Missing URL Target", "request": {}}
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"name": "Missing URL Target",
|
|
||||||
"request": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. No URL found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. No URL found\."):
|
||||||
Notification.validate(target_dict)
|
Notification.validate(target_dict)
|
||||||
|
|
@ -627,8 +522,8 @@ class TestNotification:
|
||||||
"name": "Invalid Method Target",
|
"name": "Invalid Method Target",
|
||||||
"request": {
|
"request": {
|
||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"method": "GET" # Only POST and PUT are allowed
|
"method": "GET", # Only POST and PUT are allowed
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid method found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid method found\."):
|
||||||
|
|
@ -641,8 +536,8 @@ class TestNotification:
|
||||||
"name": "Invalid Type Target",
|
"name": "Invalid Type Target",
|
||||||
"request": {
|
"request": {
|
||||||
"url": "https://example.com/webhook",
|
"url": "https://example.com/webhook",
|
||||||
"type": "xml" # Only json and form are allowed
|
"type": "xml", # Only json and form are allowed
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."):
|
||||||
|
|
@ -653,9 +548,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.EventBus")
|
@patch("app.library.Notifications.EventBus")
|
||||||
def test_attach(self, mock_eventbus, mock_worker, mock_config):
|
def test_attach(self, mock_eventbus, mock_worker, mock_config):
|
||||||
"""Test attach method."""
|
"""Test attach method."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
mock_eventbus_instance = Mock()
|
mock_eventbus_instance = Mock()
|
||||||
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
||||||
|
|
@ -674,16 +567,11 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
async def test_send_no_targets(self, mock_worker, mock_config):
|
async def test_send_no_targets(self, mock_worker, mock_config):
|
||||||
"""Test send method with no targets."""
|
"""Test send method with no targets."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
event = Event(
|
event = Event(event="test", data={"test": "data"})
|
||||||
event="test",
|
|
||||||
data={"test": "data"}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await notification.send(event)
|
result = await notification.send(event)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
@ -693,28 +581,18 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
async def test_send_invalid_event_data(self, mock_worker, mock_config):
|
async def test_send_invalid_event_data(self, mock_worker, mock_config):
|
||||||
"""Test send method with invalid event data."""
|
"""Test send method with invalid event data."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
# Add a target
|
# Add a target
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
notification._targets = [target]
|
notification._targets = [target]
|
||||||
|
|
||||||
event = Event(
|
event = Event(
|
||||||
event="test",
|
event="test",
|
||||||
data="invalid_string_data" # Should be ItemDTO or dict
|
data="invalid_string_data", # Should be ItemDTO or dict
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await notification.send(event)
|
result = await notification.send(event)
|
||||||
|
|
@ -725,9 +603,7 @@ class TestNotification:
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
async def test_send_with_http_target(self, mock_worker, mock_config):
|
async def test_send_with_http_target(self, mock_worker, mock_config):
|
||||||
"""Test send method with HTTP target."""
|
"""Test send method with HTTP target."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
# Mock HTTP client
|
# Mock HTTP client
|
||||||
|
|
@ -740,29 +616,15 @@ class TestNotification:
|
||||||
notification = Notification.get_instance(client=mock_client)
|
notification = Notification.get_instance(client=mock_client)
|
||||||
|
|
||||||
# Add HTTP target
|
# Add HTTP target
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test HTTP Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test HTTP Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
notification._targets = [target]
|
notification._targets = [target]
|
||||||
|
|
||||||
# Create test event
|
# Create test event
|
||||||
item_dto = ItemDTO(
|
item_dto = ItemDTO(
|
||||||
id="test_id",
|
id="test_id", url="https://youtube.com/watch?v=test", title="Test Video", folder="/downloads"
|
||||||
url="https://youtube.com/watch?v=test",
|
|
||||||
title="Test Video",
|
|
||||||
folder="/downloads"
|
|
||||||
)
|
|
||||||
event = Event(
|
|
||||||
event="item_completed",
|
|
||||||
data=item_dto
|
|
||||||
)
|
)
|
||||||
|
event = Event(event="item_completed", data=item_dto)
|
||||||
|
|
||||||
result = await notification.send(event)
|
result = await notification.send(event)
|
||||||
|
|
||||||
|
|
@ -777,35 +639,21 @@ class TestNotification:
|
||||||
async def test_send_with_apprise_target(self, mock_worker, mock_config):
|
async def test_send_with_apprise_target(self, mock_worker, mock_config):
|
||||||
"""Test send method with Apprise target."""
|
"""Test send method with Apprise target."""
|
||||||
mock_config.get_instance.return_value = Mock(
|
mock_config.get_instance.return_value = Mock(
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0",
|
debug=False, config_path="/tmp", app_version="1.0.0", apprise_config="/tmp/apprise.conf"
|
||||||
apprise_config="/tmp/apprise.conf"
|
|
||||||
)
|
)
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
# Add Apprise target (non-HTTP URL)
|
# Add Apprise target (non-HTTP URL)
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="discord://webhook_id/webhook_token")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Discord Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="discord://webhook_id/webhook_token"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Discord Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
notification._targets = [target]
|
notification._targets = [target]
|
||||||
|
|
||||||
# Create test event
|
# Create test event
|
||||||
event = Event(
|
event = Event(event="item_completed", data={"test": "data"})
|
||||||
event="item_completed",
|
|
||||||
data={"test": "data"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("app.library.Notifications.Path") as mock_path, \
|
|
||||||
patch("builtins.__import__") as mock_import:
|
|
||||||
|
|
||||||
|
with patch("app.library.Notifications.Path") as mock_path, patch("builtins.__import__") as mock_import:
|
||||||
# Mock apprise config file doesn't exist
|
# Mock apprise config file doesn't exist
|
||||||
mock_path.return_value.exists.return_value = False
|
mock_path.return_value.exists.return_value = False
|
||||||
|
|
||||||
|
|
@ -825,103 +673,70 @@ class TestNotification:
|
||||||
|
|
||||||
def test_check_preset_no_presets(self):
|
def test_check_preset_no_presets(self):
|
||||||
"""Test _check_preset method with target having no preset filters."""
|
"""Test _check_preset method with target having no preset filters."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
target = Target(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name="No Preset Filter",
|
name="No Preset Filter",
|
||||||
presets=[], # No preset filter
|
presets=[], # No preset filter
|
||||||
request=request
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
event = Event(
|
event = Event(event="item_completed", data={"preset": "default"})
|
||||||
event="item_completed",
|
|
||||||
data={"preset": "default"}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = notification._check_preset(target, event)
|
result = notification._check_preset(target, event)
|
||||||
assert result is True # Should pass when no preset filter
|
assert result is True # Should pass when no preset filter
|
||||||
|
|
||||||
def test_check_preset_with_matching_preset(self):
|
def test_check_preset_with_matching_preset(self):
|
||||||
"""Test _check_preset method with matching preset."""
|
"""Test _check_preset method with matching preset."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
target = Target(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()), name="Preset Filter", presets=["default", "audio_only"], request=request
|
||||||
name="Preset Filter",
|
|
||||||
presets=["default", "audio_only"],
|
|
||||||
request=request
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test with ItemDTO
|
# Test with ItemDTO
|
||||||
item_dto = ItemDTO(
|
item_dto = ItemDTO(
|
||||||
id="test_id",
|
id="test_id", url="https://youtube.com/test", title="Test Video", folder="/downloads", preset="default"
|
||||||
url="https://youtube.com/test",
|
|
||||||
title="Test Video",
|
|
||||||
folder="/downloads",
|
|
||||||
preset="default"
|
|
||||||
)
|
|
||||||
event = Event(
|
|
||||||
event="item_completed",
|
|
||||||
data=item_dto
|
|
||||||
)
|
)
|
||||||
|
event = Event(event="item_completed", data=item_dto)
|
||||||
|
|
||||||
result = notification._check_preset(target, event)
|
result = notification._check_preset(target, event)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_check_preset_with_non_matching_preset(self):
|
def test_check_preset_with_non_matching_preset(self):
|
||||||
"""Test _check_preset method with non-matching preset."""
|
"""Test _check_preset method with non-matching preset."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Preset Filter", presets=["audio_only"], request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Preset Filter",
|
|
||||||
presets=["audio_only"],
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
event = Event(
|
event = Event(
|
||||||
event="item_completed",
|
event="item_completed",
|
||||||
data={"preset": "video_only"} # Different preset
|
data={"preset": "video_only"}, # Different preset
|
||||||
)
|
)
|
||||||
|
|
||||||
result = notification._check_preset(target, event)
|
result = notification._check_preset(target, event)
|
||||||
|
|
@ -929,35 +744,23 @@ class TestNotification:
|
||||||
|
|
||||||
def test_emit_invalid_event(self):
|
def test_emit_invalid_event(self):
|
||||||
"""Test emit method with invalid event."""
|
"""Test emit method with invalid event."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker_instance = Mock()
|
mock_worker_instance = Mock()
|
||||||
mock_worker.get_instance.return_value = mock_worker_instance
|
mock_worker.get_instance.return_value = mock_worker_instance
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
# Add a target
|
# Add a target
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
notification._targets = [target]
|
notification._targets = [target]
|
||||||
|
|
||||||
# Create invalid event
|
# Create invalid event
|
||||||
event = Event(
|
event = Event(event="invalid_event", data={"test": "data"})
|
||||||
event="invalid_event",
|
|
||||||
data={"test": "data"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=False):
|
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=False):
|
||||||
result = notification.emit(event, None)
|
result = notification.emit(event, None)
|
||||||
|
|
@ -968,35 +771,23 @@ class TestNotification:
|
||||||
|
|
||||||
def test_emit_valid_event(self):
|
def test_emit_valid_event(self):
|
||||||
"""Test emit method with valid event."""
|
"""Test emit method with valid event."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker_instance = Mock()
|
mock_worker_instance = Mock()
|
||||||
mock_worker.get_instance.return_value = mock_worker_instance
|
mock_worker.get_instance.return_value = mock_worker_instance
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
||||||
# Add a target
|
# Add a target
|
||||||
request = TargetRequest(
|
request = TargetRequest(type="json", method="POST", url="https://example.com/webhook")
|
||||||
type="json",
|
target = Target(id=str(uuid.uuid4()), name="Test Target", request=request)
|
||||||
method="POST",
|
|
||||||
url="https://example.com/webhook"
|
|
||||||
)
|
|
||||||
target = Target(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name="Test Target",
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
notification._targets = [target]
|
notification._targets = [target]
|
||||||
|
|
||||||
# Create valid event
|
# Create valid event
|
||||||
event = Event(
|
event = Event(event="item_completed", data={"test": "data"})
|
||||||
event="item_completed",
|
|
||||||
data={"test": "data"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=True):
|
with patch("app.library.Notifications.NotificationEvents.is_valid", return_value=True):
|
||||||
result = notification.emit(event, None)
|
result = notification.emit(event, None)
|
||||||
|
|
@ -1008,12 +799,11 @@ class TestNotification:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_noop(self):
|
async def test_noop(self):
|
||||||
"""Test noop method."""
|
"""Test noop method."""
|
||||||
with patch("app.library.Notifications.Config") as mock_config, \
|
with (
|
||||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
patch("app.library.Notifications.Config") as mock_config,
|
||||||
|
patch("app.library.Notifications.BackgroundWorker") as mock_worker,
|
||||||
mock_config.get_instance.return_value = Mock(
|
):
|
||||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
)
|
|
||||||
mock_worker.get_instance.return_value = Mock()
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
notification = Notification.get_instance()
|
notification = Notification.get_instance()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for the generic operations module."""
|
"""Tests for the generic operations module."""
|
||||||
|
|
||||||
|
|
||||||
from app.library.operations import (
|
from app.library.operations import (
|
||||||
Operation,
|
Operation,
|
||||||
filter_items,
|
filter_items,
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,9 @@ class TestActionAndCheck:
|
||||||
@patch.object(PackageInstaller, "_install_pkg")
|
@patch.object(PackageInstaller, "_install_pkg")
|
||||||
@patch.object(PackageInstaller, "_get_installed_version")
|
@patch.object(PackageInstaller, "_get_installed_version")
|
||||||
@patch.object(PackageInstaller, "_get_latest_version")
|
@patch.object(PackageInstaller, "_get_latest_version")
|
||||||
def test_action_upgrade_skip_when_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
def test_action_upgrade_skip_when_latest(
|
||||||
|
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
inst = PackageInstaller(pkg_path=tmp_path)
|
inst = PackageInstaller(pkg_path=tmp_path)
|
||||||
mock_get_installed.return_value = "2.0.0"
|
mock_get_installed.return_value = "2.0.0"
|
||||||
mock_get_latest.return_value = "2.0.0"
|
mock_get_latest.return_value = "2.0.0"
|
||||||
|
|
@ -226,7 +228,9 @@ class TestActionAndCheck:
|
||||||
@patch.object(PackageInstaller, "_install_pkg")
|
@patch.object(PackageInstaller, "_install_pkg")
|
||||||
@patch.object(PackageInstaller, "_get_installed_version")
|
@patch.object(PackageInstaller, "_get_installed_version")
|
||||||
@patch.object(PackageInstaller, "_get_latest_version")
|
@patch.object(PackageInstaller, "_get_latest_version")
|
||||||
def test_action_upgrade_runs_when_newer_available(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
def test_action_upgrade_runs_when_newer_available(
|
||||||
|
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
inst = PackageInstaller(pkg_path=tmp_path)
|
inst = PackageInstaller(pkg_path=tmp_path)
|
||||||
mock_get_installed.return_value = "1.0.0"
|
mock_get_installed.return_value = "1.0.0"
|
||||||
mock_get_latest.return_value = "1.1.0"
|
mock_get_latest.return_value = "1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.Mo
|
||||||
|
|
||||||
# Patch module-level quote to be robust for Path objects
|
# Patch module-level quote to be robust for Path objects
|
||||||
from urllib.parse import quote as _std_quote
|
from urllib.parse import quote as _std_quote
|
||||||
|
|
||||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||||
|
|
||||||
pl = Playlist(download_path=base, url="http://localhost/")
|
pl = Playlist(download_path=base, url="http://localhost/")
|
||||||
|
|
@ -67,6 +68,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
||||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
|
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
|
||||||
|
|
||||||
from urllib.parse import quote as _std_quote
|
from urllib.parse import quote as _std_quote
|
||||||
|
|
||||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||||
|
|
||||||
pl = Playlist(download_path=base, url="https://server/")
|
pl = Playlist(download_path=base, url="https://server/")
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,17 @@ class TestScheduler:
|
||||||
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
|
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
|
||||||
# Cron stub that auto-runs the function on creation when start=True
|
# Cron stub that auto-runs the function on creation when start=True
|
||||||
class AutoRunCron(DummyCron):
|
class AutoRunCron(DummyCron):
|
||||||
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
*_,
|
||||||
|
spec: str,
|
||||||
|
func: callable,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
start: bool | None = None,
|
||||||
|
loop: asyncio.AbstractEventLoop | None = None,
|
||||||
|
):
|
||||||
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
||||||
if start:
|
if start:
|
||||||
# Simulate immediate execution
|
# Simulate immediate execution
|
||||||
|
|
@ -253,7 +263,17 @@ class TestScheduler:
|
||||||
async def test_event_schedule_runs_function(self, cron_patch) -> None:
|
async def test_event_schedule_runs_function(self, cron_patch) -> None:
|
||||||
# Cron stub that auto-runs the function on creation
|
# Cron stub that auto-runs the function on creation
|
||||||
class AutoRunCron(DummyCron):
|
class AutoRunCron(DummyCron):
|
||||||
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
*_,
|
||||||
|
spec: str,
|
||||||
|
func: callable,
|
||||||
|
args: tuple = (),
|
||||||
|
kwargs: dict | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
start: bool | None = None,
|
||||||
|
loop: asyncio.AbstractEventLoop | None = None,
|
||||||
|
):
|
||||||
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
||||||
if start:
|
if start:
|
||||||
self.func(*self.args, **self.kwargs)
|
self.func(*self.args, **self.kwargs)
|
||||||
|
|
|
||||||
|
|
@ -178,9 +178,7 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
|
||||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||||
# Simulate no /dev/dri present but GPU encoders otherwise available
|
# Simulate no /dev/dri present but GPU encoders otherwise available
|
||||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
|
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"})
|
||||||
"app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# reset encoder cache to ensure clean selection in this test
|
# reset encoder cache to ensure clean selection in this test
|
||||||
from app.library.Segments import Segments as _Seg
|
from app.library.Segments import Segments as _Seg
|
||||||
|
|
@ -257,8 +255,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
|
||||||
assert len(resp.data) > 0
|
assert len(resp.data) > 0
|
||||||
# Ensure we logged the reason for GPU failure (message text may vary)
|
# Ensure we logged the reason for GPU failure (message text may vary)
|
||||||
assert any(
|
assert any(
|
||||||
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message)
|
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) for r in caplog.records
|
||||||
for r in caplog.records
|
|
||||||
)
|
)
|
||||||
assert any("nvenc failure" in r.message for r in caplog.records)
|
assert any("nvenc failure" in r.message for r in caplog.records)
|
||||||
# Verify second invocation switched codec to a safe fallback (software)
|
# Verify second invocation switched codec to a safe fallback (software)
|
||||||
|
|
@ -269,9 +266,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_gpu_fallback_switches_codec(
|
async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
async def fake_ffprobe(_file: Path):
|
async def fake_ffprobe(_file: Path):
|
||||||
return DummyFF(v=True, a=True)
|
return DummyFF(v=True, a=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,7 @@ class TestServices:
|
||||||
def test_add_all_services(self):
|
def test_add_all_services(self):
|
||||||
"""Test adding multiple services at once."""
|
"""Test adding multiple services at once."""
|
||||||
services = Services()
|
services = Services()
|
||||||
services_dict = {
|
services_dict = {"service1": "value1", "service2": "value2", "service3": "value3"}
|
||||||
"service1": "value1",
|
|
||||||
"service2": "value2",
|
|
||||||
"service3": "value3"
|
|
||||||
}
|
|
||||||
|
|
||||||
services.add_all(services_dict)
|
services.add_all(services_dict)
|
||||||
|
|
||||||
|
|
@ -394,6 +390,7 @@ class TestServices:
|
||||||
# Lambda function replacement
|
# Lambda function replacement
|
||||||
def lambda_handler(x):
|
def lambda_handler(x):
|
||||||
return f"Lambda: {x}"
|
return f"Lambda: {x}"
|
||||||
|
|
||||||
services.add("x", "lambda_value")
|
services.add("x", "lambda_value")
|
||||||
result = services.handle_sync(lambda_handler)
|
result = services.handle_sync(lambda_handler)
|
||||||
assert result == "Lambda: lambda_value"
|
assert result == "Lambda: lambda_value"
|
||||||
|
|
@ -448,10 +445,7 @@ class TestServices:
|
||||||
services = Services()
|
services = Services()
|
||||||
services.add("existing", "original")
|
services.add("existing", "original")
|
||||||
|
|
||||||
new_services = {
|
new_services = {"existing": "overwritten", "new": "value"}
|
||||||
"existing": "overwritten",
|
|
||||||
"new": "value"
|
|
||||||
}
|
|
||||||
|
|
||||||
services.add_all(new_services)
|
services.add_all(new_services)
|
||||||
|
|
||||||
|
|
@ -531,7 +525,9 @@ class TestServices:
|
||||||
assert result == "anon: value"
|
assert result == "anon: value"
|
||||||
|
|
||||||
# Function with minimal signature info
|
# Function with minimal signature info
|
||||||
def minimal(param): return param
|
def minimal(param):
|
||||||
|
return param
|
||||||
|
|
||||||
result = services.handle_sync(minimal)
|
result = services.handle_sync(minimal)
|
||||||
assert result == "value"
|
assert result == "value"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_singleton_same_instance(self):
|
def test_singleton_same_instance(self):
|
||||||
"""Test that Singleton returns same instance for same class."""
|
"""Test that Singleton returns same instance for same class."""
|
||||||
|
|
||||||
class TestClass(metaclass=Singleton):
|
class TestClass(metaclass=Singleton):
|
||||||
def __init__(self, value=None):
|
def __init__(self, value=None):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
@ -44,6 +45,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_singleton_different_classes(self):
|
def test_singleton_different_classes(self):
|
||||||
"""Test that Singleton creates different instances for different classes."""
|
"""Test that Singleton creates different instances for different classes."""
|
||||||
|
|
||||||
class ClassA(metaclass=Singleton):
|
class ClassA(metaclass=Singleton):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = "A"
|
self.name = "A"
|
||||||
|
|
@ -68,6 +70,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_threadsafe_same_instance(self):
|
def test_threadsafe_same_instance(self):
|
||||||
"""Test that ThreadSafe returns same instance for same class."""
|
"""Test that ThreadSafe returns same instance for same class."""
|
||||||
|
|
||||||
class TestClass(metaclass=ThreadSafe):
|
class TestClass(metaclass=ThreadSafe):
|
||||||
def __init__(self, value=None):
|
def __init__(self, value=None):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
@ -83,6 +86,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_threadsafe_different_classes(self):
|
def test_threadsafe_different_classes(self):
|
||||||
"""Test that ThreadSafe creates different instances for different classes."""
|
"""Test that ThreadSafe creates different instances for different classes."""
|
||||||
|
|
||||||
class ClassA(metaclass=ThreadSafe):
|
class ClassA(metaclass=ThreadSafe):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.name = "A"
|
self.name = "A"
|
||||||
|
|
@ -153,6 +157,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_singleton_inheritance(self):
|
def test_singleton_inheritance(self):
|
||||||
"""Test singleton behavior with inheritance."""
|
"""Test singleton behavior with inheritance."""
|
||||||
|
|
||||||
class BaseClass(metaclass=Singleton):
|
class BaseClass(metaclass=Singleton):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_value = "base"
|
self.base_value = "base"
|
||||||
|
|
@ -182,6 +187,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_threadsafe_inheritance(self):
|
def test_threadsafe_inheritance(self):
|
||||||
"""Test threadsafe singleton behavior with inheritance."""
|
"""Test threadsafe singleton behavior with inheritance."""
|
||||||
|
|
||||||
class BaseClass(metaclass=ThreadSafe):
|
class BaseClass(metaclass=ThreadSafe):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_value = "base"
|
self.base_value = "base"
|
||||||
|
|
@ -211,6 +217,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_singleton_with_args_and_kwargs(self):
|
def test_singleton_with_args_and_kwargs(self):
|
||||||
"""Test singleton behavior with various constructor arguments."""
|
"""Test singleton behavior with various constructor arguments."""
|
||||||
|
|
||||||
class ConfigClass(metaclass=Singleton):
|
class ConfigClass(metaclass=Singleton):
|
||||||
def __init__(self, name, value=None, **kwargs):
|
def __init__(self, name, value=None, **kwargs):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
@ -231,6 +238,7 @@ class TestSingleton:
|
||||||
|
|
||||||
def test_threadsafe_with_args_and_kwargs(self):
|
def test_threadsafe_with_args_and_kwargs(self):
|
||||||
"""Test threadsafe singleton behavior with various constructor arguments."""
|
"""Test threadsafe singleton behavior with various constructor arguments."""
|
||||||
|
|
||||||
class ConfigClass(metaclass=ThreadSafe):
|
class ConfigClass(metaclass=ThreadSafe):
|
||||||
def __init__(self, name, value=None, **kwargs):
|
def __init__(self, name, value=None, **kwargs):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
@ -252,4 +260,5 @@ class TestSingleton:
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.main([__file__])
|
pytest.main([__file__])
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ async def test_paginate_order_and_bounds():
|
||||||
assert items_desc[0][0] == _list[14]._id
|
assert items_desc[0][0] == _list[14]._id
|
||||||
await store.close()
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_by_id():
|
async def test_get_by_id():
|
||||||
store = await make_store()
|
store = await make_store()
|
||||||
|
|
@ -112,6 +113,7 @@ async def test_get_by_id():
|
||||||
assert item._id == (await store.get_by_id("queue", item._id))._id
|
assert item._id == (await store.get_by_id("queue", item._id))._id
|
||||||
await store.close()
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shutdown_closes_worker_queue():
|
async def test_shutdown_closes_worker_queue():
|
||||||
store = await make_store()
|
store = await make_store()
|
||||||
|
|
@ -213,6 +215,7 @@ async def test_get_item_matches_conditions():
|
||||||
assert no_match is None
|
assert no_match is None
|
||||||
await store.close()
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_shutdown_closes_connection():
|
async def test_on_shutdown_closes_connection():
|
||||||
store = await make_store()
|
store = await make_store()
|
||||||
|
|
|
||||||
|
|
@ -888,9 +888,7 @@ class TestTasks:
|
||||||
@patch("app.library.Tasks.EventBus")
|
@patch("app.library.Tasks.EventBus")
|
||||||
@patch("app.library.Tasks.Scheduler")
|
@patch("app.library.Tasks.Scheduler")
|
||||||
@patch("app.library.Tasks.DownloadQueue")
|
@patch("app.library.Tasks.DownloadQueue")
|
||||||
async def test_tasks_runner_disabled_task(
|
async def test_tasks_runner_disabled_task(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||||
self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
|
||||||
):
|
|
||||||
"""Test that disabled tasks are skipped by the runner."""
|
"""Test that disabled tasks are skipped by the runner."""
|
||||||
mock_config_instance = Mock(
|
mock_config_instance = Mock(
|
||||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,10 @@ async def test_tver_handler_extract(monkeypatch):
|
||||||
assert "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅" == result.items[0].title
|
assert "木村拓哉がタクシー 運転手!目黒蓮が木村と二人旅" == result.items[0].title
|
||||||
|
|
||||||
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx"
|
assert result.items[1].url == "https://tver.jp/episodes/epejwb9mvx"
|
||||||
assert "木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!" == result.items[1].title
|
assert (
|
||||||
|
"木村拓哉がタクシー運転手!蒼井優&上戸彩高校の同級生コンビがディズニーリゾートを大満喫!"
|
||||||
|
== result.items[1].title
|
||||||
|
)
|
||||||
|
|
||||||
assert result.metadata.get("has_entries") is True
|
assert result.metadata.get("has_entries") is True
|
||||||
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")
|
assert "callSeriesEpisodes" in result.metadata.get("feed_url", "")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from app.library.Utils import REMOVE_KEYS
|
from app.library.Utils import REMOVE_KEYS
|
||||||
|
|
|
||||||
|
|
@ -75,40 +75,33 @@ exclude = [
|
||||||
"venv",
|
"venv",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Same as Black.
|
|
||||||
line-length = 120
|
line-length = 120
|
||||||
indent-width = 4
|
indent-width = 4
|
||||||
|
|
||||||
# Assume Python 3.11
|
target-version = "py313"
|
||||||
target-version = "py311"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
installer = ["pyinstaller"]
|
installer = ["pyinstaller"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
select = ["ALL"]
|
||||||
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
per-file-ignores = { "test_*.py" = [
|
||||||
# McCabe complexity (`C901`) by default.
|
"ALL",
|
||||||
#select = ["E4", "E7", "E9", "F", "G", "W"]
|
], "migrations/versions/*.py" = [
|
||||||
#ignore = ["PTH", "G0", "G1", "G201"]
|
"ALL",
|
||||||
select = [
|
] }
|
||||||
"ALL", # include all the rules, including new ones
|
|
||||||
]
|
|
||||||
# Ignore some rules in test files.
|
|
||||||
per-file-ignores = { "test_*.py" = ["D"] }
|
|
||||||
|
|
||||||
ignore = [
|
ignore = [
|
||||||
#### modules
|
"ANN",
|
||||||
"ANN", # flake8-annotations
|
"COM",
|
||||||
"COM", # flake8-commas
|
"C90",
|
||||||
"C90", # mccabe complexity
|
"DJ",
|
||||||
"DJ", # django
|
"EXE",
|
||||||
"EXE", # flake8-executable
|
"T10",
|
||||||
"T10", # debugger
|
"TID",
|
||||||
"TID", # flake8-tidy-imports
|
"PTH",
|
||||||
|
|
||||||
#### specific rules
|
"D100",
|
||||||
"D100", # ignore missing docs
|
|
||||||
"D101",
|
"D101",
|
||||||
"D102",
|
"D102",
|
||||||
"D103",
|
"D103",
|
||||||
|
|
@ -122,14 +115,13 @@ ignore = [
|
||||||
"D400",
|
"D400",
|
||||||
"D401",
|
"D401",
|
||||||
"D415",
|
"D415",
|
||||||
"E402", # false positives for local imports
|
"E402",
|
||||||
"E501", # line too long
|
"E501",
|
||||||
"TRY003", # external messages in exceptions are too verbose
|
"TRY003",
|
||||||
"TD002",
|
"TD002",
|
||||||
"TD003",
|
"TD003",
|
||||||
"FIX002", # too verbose descriptions of todos,
|
"FIX002",
|
||||||
"N806", # variable names should be snake_case
|
"N806",
|
||||||
"PTH", # prefer using absolute imports
|
|
||||||
"PGH003",
|
"PGH003",
|
||||||
"D404",
|
"D404",
|
||||||
"PLR0913",
|
"PLR0913",
|
||||||
|
|
@ -165,49 +157,25 @@ ignore = [
|
||||||
"PLC0415",
|
"PLC0415",
|
||||||
"S603",
|
"S603",
|
||||||
"D203",
|
"D203",
|
||||||
"TRY004", # Like it's our choice to use ValuesError :|
|
"TRY004",
|
||||||
"PT011",
|
"PT011",
|
||||||
"RUF001", # We like unicode chars.
|
"RUF001",
|
||||||
"S311", # Not used for cryptography
|
"S311",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
||||||
fixable = ["ALL"]
|
fixable = ["ALL"]
|
||||||
unfixable = []
|
unfixable = []
|
||||||
|
|
||||||
# Allow unused variables when underscore-prefixed.
|
|
||||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
# Like Black, use double quotes for strings.
|
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
|
|
||||||
# Like Black, indent with spaces, rather than tabs.
|
|
||||||
indent-style = "space"
|
indent-style = "space"
|
||||||
|
|
||||||
# Like Black, respect magic trailing commas.
|
|
||||||
skip-magic-trailing-comma = false
|
skip-magic-trailing-comma = false
|
||||||
|
|
||||||
# Like Black, automatically detect the appropriate line ending.
|
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
# Enable auto-formatting of code examples in docstrings. Markdown,
|
|
||||||
# reStructuredText code/literal blocks and doctests are all supported.
|
|
||||||
#
|
|
||||||
# This is currently disabled by default, but it is planned for this
|
|
||||||
# to be opt-out in the future.
|
|
||||||
docstring-code-format = false
|
docstring-code-format = false
|
||||||
|
|
||||||
# Set the line length limit used when formatting code snippets in
|
|
||||||
# docstrings.
|
|
||||||
#
|
|
||||||
# This only has an effect when the `docstring-code-format` setting is
|
|
||||||
# enabled.
|
|
||||||
docstring-code-line-length = "dynamic"
|
docstring-code-line-length = "dynamic"
|
||||||
|
|
||||||
[tool.autopep8]
|
|
||||||
--max-line-length = 120
|
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
link-mode = "copy"
|
link-mode = "copy"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ci": "vitest run --silent --reporter=dot"
|
"test:ci": "vitest run --silent --reporter=dot",
|
||||||
|
"lint:tsc": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"web-types": "./web-types.json",
|
"web-types": "./web-types.json",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue