ytptube/app/tests/test_task_definitions.py
2025-09-23 01:41:22 +03:00

315 lines
12 KiB
Python

import json
import uuid
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from aiohttp import web
from aiohttp.web import Request
from jsonschema import Draft7Validator
from app.library.encoder import Encoder
from app.library.TaskDefinitions import TaskDefinitionRecord, TaskDefinitions
from app.routes.api import task_definitions as api
def _load_validator() -> Draft7Validator:
schema_path = Path(__file__).resolve().parent.parent / "schema" / "task_definition.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
return Draft7Validator(schema)
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict[str, Any]:
return {
"name": name,
"match": ["https://example.com/*"],
"priority": priority,
"parse": {
"items": {
"type": "css",
"selector": ".card",
"fields": {
"link": {"type": "css", "expression": "a", "attribute": "href"},
"title": {"type": "css", "expression": "a", "attribute": "text"},
},
}
},
}
class TestTaskDefinitionsManager:
def setup_method(self) -> None:
TaskDefinitions._reset_singleton()
def teardown_method(self) -> None:
TaskDefinitions._reset_singleton()
def test_load_populates_records(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
first_identifier = "b5c6ad5f-4745-4c05-88c8-dde1deae3b51"
second_identifier = "ae38a6b0-2c22-4763-ba60-801ae8ce1218"
first = tmp_path / f"{first_identifier}.json"
second = tmp_path / f"{second_identifier}.json"
first.write_text(json.dumps(_sample_definition("First", priority=5)), encoding="utf-8")
second.write_text(json.dumps(_sample_definition("Second", priority=1)), encoding="utf-8")
(tmp_path / "not-a-uuid.json").write_text(json.dumps(_sample_definition("Ignored")), encoding="utf-8")
(tmp_path / "0f9184de-5d3c-2111-8c21-6d3f0be1bd3d.json").write_text(
json.dumps(_sample_definition("Ignored2")),
encoding="utf-8",
)
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
records = manager.list()
assert len(records) == 2
assert [record.name for record in records] == ["Second", "First"]
assert [record.priority for record in records] == [1, 5]
def test_create_writes_file_and_refreshes(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
definition = _sample_definition("My Definition")
record = manager.create(definition)
refresh.assert_called_once_with(force=True)
identifier_uuid = uuid.UUID(record.identifier)
assert 4 == identifier_uuid.version
expected_filename = f"{record.identifier}.json"
saved_path = tmp_path / expected_filename
assert saved_path.exists()
saved_content = json.loads(saved_path.read_text(encoding="utf-8"))
assert saved_content["name"] == "My Definition"
assert saved_content["priority"] == 0
def test_update_missing_definition_raises(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with pytest.raises(ValueError, match="does not exist"):
manager.update("missing", _sample_definition("Updated"))
def test_update_overwrites_file(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
identifier = "c59ec7cf-6291-4f0f-86f8-d8cb12c325a4"
initial = _sample_definition("Original", priority=4)
path = tmp_path / f"{identifier}.json"
path.write_text(json.dumps(initial), encoding="utf-8")
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
updated_record = manager.update(identifier, _sample_definition("Updated", priority=2))
refresh.assert_called_once_with(force=True)
assert updated_record.name == "Updated"
assert updated_record.priority == 2
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["name"] == "Updated"
assert saved["priority"] == 2
def test_delete_removes_file(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
identifier = "f0b71f47-6b65-4b6d-89fd-6b87ce47d3bc"
definition_path = tmp_path / f"{identifier}.json"
definition_path.write_text(json.dumps(_sample_definition("Delete")), encoding="utf-8")
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
manager.delete(identifier)
refresh.assert_called_once_with(force=True)
assert not definition_path.exists()
assert manager.get(identifier) is None
@pytest.mark.asyncio
class TestTaskDefinitionRoutes:
def setup_method(self) -> None:
TaskDefinitions._reset_singleton()
def teardown_method(self) -> None:
TaskDefinitions._reset_singleton()
async def test_list_definitions(self) -> None:
request = MagicMock(spec=Request)
request.query = {}
identifier = "9af7018f-8659-4d2a-a42b-b5d2c5f0a6e2"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Sample",
priority=0,
path=Path("/tmp/sample.json"),
data=_sample_definition("Sample"),
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.list.return_value = [record]
response = await api.task_definitions_list(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert payload == [record.serialize()]
assert "filename" not in payload[0]
async def test_list_definitions_includes_definition(self) -> None:
request = MagicMock(spec=Request)
request.query = {"include": "definition"}
identifier = "f5b5e88d-5c6b-4a27-8453-1b6a4fb8a8d1"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Sample",
priority=0,
path=Path("/tmp/sample.json"),
data=_sample_definition("Sample"),
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.list.return_value = [record]
response = await api.task_definitions_list(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert payload[0]["definition"]["name"] == "Sample"
async def test_get_definition_not_found(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": "unknown"}
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.get.return_value = None
response = await api.task_definitions_get(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert response.status == web.HTTPNotFound.status_code
assert "error" in payload
async def test_create_definition_success(self) -> None:
payload_definition = _sample_definition("New", priority=1)
payload = {"definition": payload_definition}
request = MagicMock(spec=Request)
request.json = AsyncMock(return_value=payload)
identifier = "4f08b8af-b87a-4d6e-9289-39e5172898aa"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="New",
priority=1,
path=Path("/tmp/new.json"),
data=payload["definition"],
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.create.return_value = record
response = await api.task_definitions_create(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPCreated.status_code
assert body["id"] == identifier
assert body["priority"] == 1
assert "filename" not in body
task_definitions.create.assert_called_once_with(payload_definition)
async def test_create_definition_invalid_payload(self) -> None:
request = MagicMock(spec=Request)
request.json = AsyncMock(return_value=[]) # type: ignore[arg-type]
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_create(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body
async def test_update_definition_success(self) -> None:
request = MagicMock(spec=Request)
identifier = "6d8d5719-95ae-4478-bb05-986f5b72b6c1"
request.match_info = {"identifier": identifier}
definition = _sample_definition("Updated", priority=4)
request.json = AsyncMock(return_value={"definition": definition})
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Updated",
priority=4,
path=Path("/tmp/existing.json"),
data=definition,
updated_at=456.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.update.return_value = record
response = await api.task_definitions_update(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert body["name"] == "Updated"
assert body["priority"] == 4
task_definitions.update.assert_called_once_with(identifier, definition)
async def test_update_definition_missing_identifier(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": ""}
request.json = AsyncMock(return_value={})
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_update(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body
async def test_delete_definition_success(self) -> None:
request = MagicMock(spec=Request)
identifier = "c9f4ac6c-a4ab-4d1a-8d25-764dc0c8a3f0"
request.match_info = {"identifier": identifier}
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_delete(request, task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert body["status"] == "deleted"
task_definitions.delete.assert_called_once_with(identifier)
async def test_delete_definition_missing_identifier(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": ""}
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_delete(request, task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body