Feat: update datastore.get_items to support operations
This commit is contained in:
parent
98fbf00a63
commit
6d9c7d70d9
4 changed files with 1377 additions and 10 deletions
|
|
@ -9,6 +9,7 @@ from sqlite3 import Connection
|
|||
|
||||
from .Download import Download
|
||||
from .ItemDTO import ItemDTO
|
||||
from .operations import matches_condition
|
||||
from .Utils import init_class
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("datastore")
|
||||
|
|
@ -91,16 +92,31 @@ class DataStore:
|
|||
|
||||
def get_item(self, **kwargs) -> Download | None:
|
||||
"""
|
||||
Get a specific item from the datastore based on provided attributes.
|
||||
Get a specific item from the datastore based on provided attributes with optional operations.
|
||||
|
||||
Args:
|
||||
**kwargs: Arbitrary keyword arguments representing attributes of the ItemDTO.
|
||||
If no attributes are provided, the method returns None.
|
||||
If any attribute matches, the corresponding Download object is returned.
|
||||
Each value can be either:
|
||||
- A direct value (defaults to EQUAL operation): {"title": "test"}
|
||||
- A tuple of (Operation, value): {"title": (Operation.CONTAIN, "test")}
|
||||
|
||||
If no attributes are provided, the method returns None.
|
||||
If any attribute matches, the corresponding Download object is returned.
|
||||
|
||||
Returns:
|
||||
Download | None: The requested item if found, otherwise None.
|
||||
|
||||
Examples:
|
||||
# Direct equality check (default)
|
||||
store.get_item(title="Video 1")
|
||||
|
||||
# Using operations
|
||||
store.get_item(title=(Operation.CONTAIN, "test"))
|
||||
store.get_item(id=(Operation.EQUAL, "123"), status=(Operation.NOT_EQUAL, "error"))
|
||||
|
||||
# Mixed usage
|
||||
store.get_item(title=(Operation.CONTAIN, "test"), folder="downloads")
|
||||
|
||||
"""
|
||||
if not kwargs:
|
||||
return None
|
||||
|
|
@ -110,7 +126,7 @@ class DataStore:
|
|||
continue
|
||||
|
||||
info = self._dict[i].info.__dict__
|
||||
if any((key in info and info == value) for key, value in kwargs.items()):
|
||||
if any(matches_condition(key, value, info) for key, value in kwargs.items()):
|
||||
return self._dict[i]
|
||||
|
||||
return None
|
||||
|
|
@ -118,7 +134,7 @@ class DataStore:
|
|||
def get_by_id(self, id: str) -> Download | None:
|
||||
return self._dict.get(id, None)
|
||||
|
||||
def items(self) -> list[tuple[str, Download]]:
|
||||
def items(self) -> OrderedDict[tuple[str, Download]]:
|
||||
return self._dict.items()
|
||||
|
||||
def saved_items(self) -> list[tuple[str, ItemDTO]]:
|
||||
|
|
@ -234,8 +250,8 @@ class DataStore:
|
|||
|
||||
order = "ASC" if order == "ASC" else "DESC"
|
||||
|
||||
total_items = self.get_total_count()
|
||||
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
|
||||
total_items: int = self.get_total_count()
|
||||
total_pages: int = (total_items + per_page - 1) // per_page if total_items > 0 else 1
|
||||
|
||||
# Ensure page is within valid range.
|
||||
if page > total_pages and total_items > 0:
|
||||
|
|
|
|||
275
app/library/operations.py
Normal file
275
app/library/operations.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Operation(str, Enum):
|
||||
"""Comparison operations for filtering items."""
|
||||
|
||||
EQUAL = "=="
|
||||
"""Exact equality comparison."""
|
||||
NOT_EQUAL = "!="
|
||||
"""Not equal comparison."""
|
||||
CONTAIN = "in"
|
||||
"""Check if value is contained in the field (substring match)."""
|
||||
NOT_CONTAIN = "not_in"
|
||||
"""Check if value is not contained in the field."""
|
||||
GREATER_THAN = ">"
|
||||
"""Greater than comparison."""
|
||||
LESS_THAN = "<"
|
||||
"""Less than comparison."""
|
||||
GREATER_EQUAL = ">="
|
||||
"""Greater than or equal comparison."""
|
||||
LESS_EQUAL = "<="
|
||||
"""Less than or equal comparison."""
|
||||
STARTS_WITH = "startswith"
|
||||
"""Check if field starts with value."""
|
||||
ENDS_WITH = "endswith"
|
||||
"""Check if field ends with value."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
def matches(operation: Operation | str, haystack: Any, needle: Any) -> bool:
|
||||
"""
|
||||
Generic comparison function that compares two values using the specified operation.
|
||||
|
||||
Args:
|
||||
operation: The comparison operation to perform (Operation enum or string)
|
||||
haystack: The first value (usually the field value from data)
|
||||
needle: The second value (usually the comparison value)
|
||||
|
||||
Returns:
|
||||
bool: True if the comparison matches, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> matches(Operation.EQUAL, "test", "test")
|
||||
True
|
||||
>>> matches(Operation.CONTAIN, "Python Tutorial", "Python")
|
||||
True
|
||||
>>> matches(Operation.GREATER_THAN, 100, 50)
|
||||
True
|
||||
>>> matches("==", "test", "test")
|
||||
True
|
||||
|
||||
"""
|
||||
# Parse operation if it's a string
|
||||
if isinstance(operation, str):
|
||||
try:
|
||||
operation = Operation(operation)
|
||||
except ValueError:
|
||||
operation = Operation.EQUAL
|
||||
|
||||
try:
|
||||
if Operation.EQUAL == operation:
|
||||
return haystack == needle
|
||||
|
||||
if Operation.NOT_EQUAL == operation:
|
||||
return haystack != needle
|
||||
|
||||
if Operation.CONTAIN == operation:
|
||||
return str(needle) in str(haystack) if haystack is not None else False
|
||||
|
||||
if Operation.NOT_CONTAIN == operation:
|
||||
return str(needle) not in str(haystack) if haystack is not None else True
|
||||
|
||||
if Operation.GREATER_THAN == operation:
|
||||
if haystack is None or needle is None:
|
||||
return False
|
||||
return haystack > needle
|
||||
|
||||
if Operation.LESS_THAN == operation:
|
||||
if haystack is None or needle is None:
|
||||
return False
|
||||
return haystack < needle
|
||||
|
||||
if Operation.GREATER_EQUAL == operation:
|
||||
if haystack is None or needle is None:
|
||||
return False
|
||||
return haystack >= needle
|
||||
|
||||
if Operation.LESS_EQUAL == operation:
|
||||
if haystack is None or needle is None:
|
||||
return False
|
||||
return haystack <= needle
|
||||
|
||||
if Operation.STARTS_WITH == operation:
|
||||
return str(haystack).startswith(str(needle)) if haystack is not None else False
|
||||
|
||||
if Operation.ENDS_WITH == operation:
|
||||
return str(haystack).endswith(str(needle)) if haystack is not None else False
|
||||
|
||||
# Unknown operation, default to equality
|
||||
return haystack == needle
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
# Comparison failed (e.g., comparing incompatible types)
|
||||
return False
|
||||
|
||||
|
||||
def matches_condition(key: str, value: tuple | str | float | bool, data: dict) -> bool:
|
||||
"""
|
||||
Check if a field in a dictionary matches the given condition.
|
||||
|
||||
This is a helper function that extracts values from a dictionary and uses the generic
|
||||
matches() function to perform the comparison.
|
||||
|
||||
Args:
|
||||
key: The field name to check in the data dictionary
|
||||
value: Either:
|
||||
- A direct value for equality check: "test"
|
||||
- A tuple of (Operation, value): (Operation.CONTAIN, "test")
|
||||
- A tuple of (str, value): ("in", "test") for backward compatibility
|
||||
data: Dictionary containing the data to check against
|
||||
|
||||
Returns:
|
||||
bool: True if the condition matches, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> data = {"title": "Python Tutorial", "size": 1000}
|
||||
>>> matches_condition("title", "Python Tutorial", data)
|
||||
True
|
||||
>>> matches_condition("title", (Operation.CONTAIN, "Python"), data)
|
||||
True
|
||||
>>> matches_condition("size", (Operation.GREATER_THAN, 500), data)
|
||||
True
|
||||
>>> matches_condition("missing", "value", data)
|
||||
False
|
||||
|
||||
"""
|
||||
if key not in data:
|
||||
return False
|
||||
|
||||
field_value: Any = data[key]
|
||||
|
||||
# Parse value to extract operation and comparison value
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
operation, compare_value = value
|
||||
else:
|
||||
operation = Operation.EQUAL
|
||||
compare_value = value
|
||||
|
||||
return matches(operation, field_value, compare_value)
|
||||
|
||||
|
||||
def matches_all(data: dict, **conditions) -> bool:
|
||||
"""
|
||||
Check if all conditions match (AND logic).
|
||||
|
||||
Args:
|
||||
data: Dictionary containing the data to check against
|
||||
**conditions: Keyword arguments representing conditions to check
|
||||
|
||||
Returns:
|
||||
bool: True if all conditions match, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> data = {"title": "Python Tutorial", "size": 1000, "status": "active"}
|
||||
>>> matches_all(data, title=(Operation.CONTAIN, "Python"), size=(Operation.GREATER_THAN, 500))
|
||||
True
|
||||
>>> matches_all(data, title="Python Tutorial", status="active")
|
||||
True
|
||||
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
return all(matches_condition(key, value, data) for key, value in conditions.items())
|
||||
|
||||
|
||||
def matches_any(data: dict, **conditions) -> bool:
|
||||
"""
|
||||
Check if any condition matches (OR logic).
|
||||
|
||||
Args:
|
||||
data: Dictionary containing the data to check against
|
||||
**conditions: Keyword arguments representing conditions to check
|
||||
|
||||
Returns:
|
||||
bool: True if any condition matches, False if none match
|
||||
|
||||
Examples:
|
||||
>>> data = {"title": "Python Tutorial", "size": 1000}
|
||||
>>> matches_any(data, title=(Operation.CONTAIN, "Java"), size=(Operation.GREATER_THAN, 500))
|
||||
True
|
||||
>>> matches_any(data, title="Wrong", status="Wrong")
|
||||
False
|
||||
|
||||
"""
|
||||
if not conditions:
|
||||
return False
|
||||
|
||||
return any(matches_condition(key, value, data) for key, value in conditions.items())
|
||||
|
||||
|
||||
def filter_items(items: list[dict], **conditions) -> list[dict]:
|
||||
"""
|
||||
Filter a list of dictionaries based on conditions (AND logic).
|
||||
|
||||
Args:
|
||||
items: List of dictionaries to filter
|
||||
**conditions: Keyword arguments representing conditions to check
|
||||
|
||||
Returns:
|
||||
list[dict]: Filtered list of dictionaries that match all conditions
|
||||
|
||||
Examples:
|
||||
>>> items = [
|
||||
... {"title": "Python Tutorial", "size": 1000},
|
||||
... {"title": "JavaScript Course", "size": 2000},
|
||||
... {"title": "Python Advanced", "size": 1500}
|
||||
... ]
|
||||
>>> filter_items(items, title=(Operation.CONTAIN, "Python"))
|
||||
[{"title": "Python Tutorial", "size": 1000}, {"title": "Python Advanced", "size": 1500}]
|
||||
>>> filter_items(items, size=(Operation.GREATER_THAN, 1200))
|
||||
[{"title": "JavaScript Course", "size": 2000}, {"title": "Python Advanced", "size": 1500}]
|
||||
|
||||
"""
|
||||
if not conditions:
|
||||
return items
|
||||
|
||||
return [item for item in items if matches_all(item, **conditions)]
|
||||
|
||||
|
||||
def find_first(items: list[dict], **conditions) -> dict | None:
|
||||
"""
|
||||
Find the first dictionary that matches all conditions (AND logic).
|
||||
|
||||
Args:
|
||||
items: List of dictionaries to search
|
||||
**conditions: Keyword arguments representing conditions to check
|
||||
|
||||
Returns:
|
||||
dict | None: First matching dictionary or None if no match found
|
||||
|
||||
Examples:
|
||||
>>> items = [
|
||||
... {"title": "Python Tutorial", "size": 1000},
|
||||
... {"title": "JavaScript Course", "size": 2000}
|
||||
... ]
|
||||
>>> find_first(items, title=(Operation.CONTAIN, "Python"))
|
||||
{"title": "Python Tutorial", "size": 1000}
|
||||
>>> find_first(items, title="Nonexistent")
|
||||
None
|
||||
|
||||
"""
|
||||
for item in items:
|
||||
if matches_all(item, **conditions):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def find_all(items: list[dict], **conditions) -> list[dict]:
|
||||
"""
|
||||
Alias for filter_items() - find all dictionaries matching conditions.
|
||||
|
||||
Args:
|
||||
items: List of dictionaries to search
|
||||
**conditions: Keyword arguments representing conditions to check
|
||||
|
||||
Returns:
|
||||
list[dict]: List of matching dictionaries
|
||||
|
||||
"""
|
||||
return filter_items(items, **conditions)
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import sqlite3
|
||||
from collections import OrderedDict
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import formatdate
|
||||
|
|
@ -8,6 +9,7 @@ import pytest
|
|||
|
||||
from app.library.DataStore import DataStore, StoreType
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
from app.library.operations import Operation
|
||||
|
||||
|
||||
class StubDownload:
|
||||
|
|
@ -26,9 +28,7 @@ class StubDownload:
|
|||
def make_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"CREATE TABLE history (id TEXT PRIMARY KEY, type TEXT, url TEXT, data TEXT, created_at TEXT)"
|
||||
)
|
||||
conn.execute("CREATE TABLE history (id TEXT PRIMARY KEY, type TEXT, url TEXT, data TEXT, created_at TEXT)")
|
||||
return conn
|
||||
|
||||
|
||||
|
|
@ -155,3 +155,835 @@ class TestDataStore:
|
|||
# Should not raise
|
||||
ok = await store.test()
|
||||
assert ok is True
|
||||
|
||||
def test_get_item_returns_none_when_no_kwargs(self) -> None:
|
||||
"""Test that get_item returns None when no kwargs provided."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
result = store.get_item()
|
||||
assert result is None
|
||||
|
||||
def test_get_item_finds_by_single_attribute(self) -> None:
|
||||
"""Test that get_item correctly finds item by a single attribute."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Create items with different attributes
|
||||
item1 = make_item(id="vid1", url="http://example.com/1", title="Video 1", folder="folder1")
|
||||
item1._id = "id1" # Override auto-generated UUID
|
||||
item2 = make_item(id="vid2", url="http://example.com/2", title="Video 2", folder="folder2")
|
||||
item2._id = "id2" # Override auto-generated UUID
|
||||
|
||||
d1 = StubDownload(info=item1)
|
||||
d2 = StubDownload(info=item2)
|
||||
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
|
||||
# Test finding by title
|
||||
result = store.get_item(title="Video 1")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
assert result.info.title == "Video 1"
|
||||
|
||||
# Test finding by folder
|
||||
result = store.get_item(folder="folder2")
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
assert result.info.folder == "folder2"
|
||||
|
||||
# Test finding by url
|
||||
result = store.get_item(url="http://example.com/1")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
def test_get_item_finds_by_multiple_attributes(self) -> None:
|
||||
"""Test that get_item finds item when ANY of the provided attributes match."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", url="http://example.com/1", title="Video 1", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", url="http://example.com/2", title="Video 2", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
d1 = StubDownload(info=item1)
|
||||
d2 = StubDownload(info=item2)
|
||||
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
|
||||
# Test finding by multiple attributes where one matches
|
||||
result = store.get_item(title="Video 1", folder="wrong_folder")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Test finding where second attribute matches
|
||||
result = store.get_item(title="Wrong Title", folder="folder2")
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
def test_get_item_returns_none_when_no_match(self) -> None:
|
||||
"""Test that get_item returns None when no attributes match."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1", url="http://example.com/1", title="Video 1", folder="folder1")
|
||||
item._id = "id1"
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
# Test with non-matching attribute
|
||||
result = store.get_item(title="Nonexistent Video")
|
||||
assert result is None
|
||||
|
||||
# Test with non-existent attribute key
|
||||
result = store.get_item(nonexistent_field="value")
|
||||
assert result is None
|
||||
|
||||
def test_get_item_skips_items_with_no_info(self) -> None:
|
||||
"""Test that get_item skips items that have no info attribute."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Create a valid item
|
||||
item = make_item(id="vid1", url="http://example.com/1", title="Video 1")
|
||||
item._id = "id1"
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
# Manually add an item with None info
|
||||
class BrokenDownload:
|
||||
def __init__(self):
|
||||
self.info = None
|
||||
|
||||
store._dict["broken"] = BrokenDownload()
|
||||
|
||||
# Should still find the valid item
|
||||
result = store.get_item(title="Video 1")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
def test_get_item_returns_first_match(self) -> None:
|
||||
"""Test that get_item returns the first matching item when multiple match."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Create multiple items with same title
|
||||
item1 = make_item(id="vid1", url="http://example.com/1", title="Same Title", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", url="http://example.com/2", title="Same Title", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
d1 = StubDownload(info=item1)
|
||||
d2 = StubDownload(info=item2)
|
||||
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
|
||||
# Should return first match (note: OrderedDict maintains insertion order)
|
||||
result = store.get_item(title="Same Title")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Test DataStore initialization."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
assert store._type == StoreType.QUEUE
|
||||
assert store._connection is conn
|
||||
assert isinstance(store._dict, OrderedDict)
|
||||
assert len(store._dict) == 0
|
||||
|
||||
def test_load(self) -> None:
|
||||
"""Test loading items from database into memory."""
|
||||
conn = make_conn()
|
||||
|
||||
# Insert items directly into database
|
||||
item1_data = asdict(make_item(id="vid1", url="http://example.com/1", title="Video 1"))
|
||||
item1_data.pop("_id", None)
|
||||
item2_data = asdict(make_item(id="vid2", url="http://example.com/2", title="Video 2"))
|
||||
item2_data.pop("_id", None)
|
||||
|
||||
created = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"id1",
|
||||
str(StoreType.QUEUE),
|
||||
"http://example.com/1",
|
||||
json.dumps(item1_data),
|
||||
created.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"id2",
|
||||
str(StoreType.QUEUE),
|
||||
"http://example.com/2",
|
||||
json.dumps(item2_data),
|
||||
created.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
# Create store and load
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
assert len(store._dict) == 0
|
||||
|
||||
store.load()
|
||||
assert len(store._dict) == 2
|
||||
assert "id1" in store._dict
|
||||
assert "id2" in store._dict
|
||||
assert store._dict["id1"].info.url == "http://example.com/1"
|
||||
assert store._dict["id2"].info.url == "http://example.com/2"
|
||||
|
||||
def test_load_with_different_store_types(self) -> None:
|
||||
"""Test that load only loads items matching the store type."""
|
||||
conn = make_conn()
|
||||
|
||||
# Insert items with different types
|
||||
item1_data = asdict(make_item(id="vid1", url="http://example.com/1"))
|
||||
item1_data.pop("_id", None)
|
||||
item2_data = asdict(make_item(id="vid2", url="http://example.com/2"))
|
||||
item2_data.pop("_id", None)
|
||||
|
||||
created = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"id1",
|
||||
str(StoreType.QUEUE),
|
||||
"http://example.com/1",
|
||||
json.dumps(item1_data),
|
||||
created.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
"id2",
|
||||
str(StoreType.HISTORY),
|
||||
"http://example.com/2",
|
||||
json.dumps(item2_data),
|
||||
created.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
# Load QUEUE store - should only get queue items
|
||||
queue_store = DataStore(StoreType.QUEUE, conn)
|
||||
queue_store.load()
|
||||
assert len(queue_store._dict) == 1
|
||||
assert "id1" in queue_store._dict
|
||||
|
||||
# Load HISTORY store - should only get history items
|
||||
history_store = DataStore(StoreType.HISTORY, conn)
|
||||
history_store.load()
|
||||
assert len(history_store._dict) == 1
|
||||
assert "id2" in history_store._dict
|
||||
|
||||
def test_get_by_id(self) -> None:
|
||||
"""Test getting item by ID."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", url="http://example.com/1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", url="http://example.com/2", title="Video 2")
|
||||
item2._id = "id2"
|
||||
|
||||
d1 = StubDownload(info=item1)
|
||||
d2 = StubDownload(info=item2)
|
||||
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
|
||||
# Test getting existing items
|
||||
result = store.get_by_id("id1")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
assert result.info.title == "Video 1"
|
||||
|
||||
result = store.get_by_id("id2")
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
# Test getting non-existent item
|
||||
result = store.get_by_id("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_items(self) -> None:
|
||||
"""Test getting all items as list of tuples."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Empty store
|
||||
result = store.items()
|
||||
assert len(list(result)) == 0
|
||||
|
||||
# Add items
|
||||
item1 = make_item(id="vid1", url="http://example.com/1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", url="http://example.com/2", title="Video 2")
|
||||
item2._id = "id2"
|
||||
|
||||
d1 = StubDownload(info=item1)
|
||||
d2 = StubDownload(info=item2)
|
||||
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
|
||||
# Test getting all items
|
||||
result = list(store.items())
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify structure (list of tuples)
|
||||
ids = [item[0] for item in result]
|
||||
assert "id1" in ids
|
||||
assert "id2" in ids
|
||||
|
||||
# Verify order is maintained (OrderedDict)
|
||||
assert result[0][0] == "id1"
|
||||
assert result[1][0] == "id2"
|
||||
|
||||
def test_get_total_count_with_empty_store(self) -> None:
|
||||
"""Test get_total_count with empty datastore."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
count = store.get_total_count()
|
||||
assert count == 0
|
||||
|
||||
def test_get_total_count_with_items(self) -> None:
|
||||
"""Test get_total_count with items in database."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Add items directly to database
|
||||
created = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
for i in range(5):
|
||||
item_data = asdict(make_item(id=f"vid{i}"))
|
||||
item_data.pop("_id", None)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(f"id{i}", str(StoreType.QUEUE), f"http://example.com/{i}", json.dumps(item_data), created),
|
||||
)
|
||||
|
||||
count = store.get_total_count()
|
||||
assert count == 5
|
||||
|
||||
def test_get_total_count_respects_store_type(self) -> None:
|
||||
"""Test that get_total_count only counts items of the correct type."""
|
||||
conn = make_conn()
|
||||
|
||||
created = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Add 3 QUEUE items
|
||||
for i in range(3):
|
||||
item_data = asdict(make_item(id=f"vid{i}"))
|
||||
item_data.pop("_id", None)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(f"q{i}", str(StoreType.QUEUE), f"http://example.com/{i}", json.dumps(item_data), created),
|
||||
)
|
||||
|
||||
# Add 2 HISTORY items
|
||||
for i in range(2):
|
||||
item_data = asdict(make_item(id=f"vid{i}"))
|
||||
item_data.pop("_id", None)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(f"h{i}", str(StoreType.HISTORY), f"http://example.com/{i}", json.dumps(item_data), created),
|
||||
)
|
||||
|
||||
queue_store = DataStore(StoreType.QUEUE, conn)
|
||||
assert queue_store.get_total_count() == 3
|
||||
|
||||
history_store = DataStore(StoreType.HISTORY, conn)
|
||||
assert history_store.get_total_count() == 2
|
||||
|
||||
def test_put_with_error_status_emits_event(self) -> None:
|
||||
"""Test that put() emits an event when item has error status."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
item.status = "error"
|
||||
item.error = "Test error message"
|
||||
|
||||
d = StubDownload(info=item)
|
||||
|
||||
# We can't easily test event emission without mocking EventBus
|
||||
# Just verify it doesn't crash
|
||||
result = store.put(d)
|
||||
assert result is not None
|
||||
|
||||
def test_put_with_no_notify_skips_event(self) -> None:
|
||||
"""Test that put() with no_notify=True doesn't emit events."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
item.status = "error"
|
||||
item.error = "Test error message"
|
||||
|
||||
d = StubDownload(info=item)
|
||||
|
||||
# Should not emit event when no_notify=True
|
||||
result = store.put(d, no_notify=True)
|
||||
assert result is not None
|
||||
assert result.info._id == item._id
|
||||
|
||||
def test_delete_nonexistent_item(self) -> None:
|
||||
"""Test that deleting non-existent item doesn't raise error."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Should not raise error
|
||||
store.delete("nonexistent_id")
|
||||
|
||||
# Verify nothing was deleted from database
|
||||
row = conn.execute("SELECT * FROM history WHERE id=?", ("nonexistent_id",)).fetchone()
|
||||
assert row is None
|
||||
|
||||
def test_has_downloads_with_empty_dict(self) -> None:
|
||||
"""Test has_downloads returns False when dict is empty."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
assert store.has_downloads() is False
|
||||
|
||||
def test_has_downloads_with_no_eligible_downloads(self) -> None:
|
||||
"""Test has_downloads returns False when no downloads are eligible."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Add item that's already started
|
||||
item1 = make_item(id="vid1")
|
||||
store.put(StubDownload(info=item1, started=True))
|
||||
|
||||
# Add item with auto_start=False
|
||||
item2 = make_item(id="vid2")
|
||||
item2.auto_start = False
|
||||
store.put(StubDownload(info=item2, started=False))
|
||||
|
||||
assert store.has_downloads() is False
|
||||
|
||||
def test_get_next_download_returns_none_when_empty(self) -> None:
|
||||
"""Test get_next_download returns None when no eligible downloads."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
result = store.get_next_download()
|
||||
assert result is None
|
||||
|
||||
def test_get_next_download_skips_cancelled(self) -> None:
|
||||
"""Test get_next_download skips cancelled downloads."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# Add cancelled download
|
||||
item1 = make_item(id="vid1")
|
||||
item1._id = "id1"
|
||||
store.put(StubDownload(info=item1, started=False, cancelled=True))
|
||||
|
||||
# Add eligible download
|
||||
item2 = make_item(id="vid2")
|
||||
item2._id = "id2"
|
||||
store.put(StubDownload(info=item2, started=False, cancelled=False))
|
||||
|
||||
result = store.get_next_download()
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
def test_update_store_item_removes_datetime_field(self) -> None:
|
||||
"""Test that _update_store_item removes datetime field before storage."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
item.datetime = "Thu, 01 Jan 2024 12:00:00 GMT" # Add datetime field
|
||||
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
# Verify datetime field is not in stored JSON
|
||||
row = conn.execute("SELECT data FROM history WHERE id=?", (item._id,)).fetchone()
|
||||
assert row is not None
|
||||
data = json.loads(row["data"])
|
||||
assert "datetime" not in data
|
||||
|
||||
def test_update_store_item_removes_live_in_when_finished(self) -> None:
|
||||
"""Test that _update_store_item removes live_in field when status is finished."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
item.status = "finished"
|
||||
item.live_in = "PT5M" # Add live_in field
|
||||
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
# Verify live_in field is not in stored JSON when status is finished
|
||||
row = conn.execute("SELECT data FROM history WHERE id=?", (item._id,)).fetchone()
|
||||
assert row is not None
|
||||
data = json.loads(row["data"])
|
||||
assert "live_in" not in data
|
||||
|
||||
def test_update_store_item_keeps_live_in_when_not_finished(self) -> None:
|
||||
"""Test that _update_store_item keeps live_in field when status is not finished."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
item.status = "downloading"
|
||||
item.live_in = "PT5M" # Add live_in field
|
||||
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
# Verify live_in field IS in stored JSON when status is not finished
|
||||
row = conn.execute("SELECT data FROM history WHERE id=?", (item._id,)).fetchone()
|
||||
assert row is not None
|
||||
data = json.loads(row["data"])
|
||||
assert "live_in" in data
|
||||
assert data["live_in"] == "PT5M"
|
||||
|
||||
|
||||
class TestDataStoreOperations:
|
||||
"""Test get_item with different comparison operations."""
|
||||
|
||||
def test_operation_equal(self) -> None:
|
||||
"""Test EQUAL operation (default behavior)."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Exact Match", folder="folder1")
|
||||
item1._id = "id1"
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Test with explicit EQUAL operation
|
||||
result = store.get_item(title=(Operation.EQUAL, "Exact Match"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Test default behavior (no operation specified)
|
||||
result = store.get_item(title="Exact Match")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Test no match
|
||||
result = store.get_item(title=(Operation.EQUAL, "No Match"))
|
||||
assert result is None
|
||||
|
||||
def test_operation_not_equal(self) -> None:
|
||||
"""Test NOT_EQUAL operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="Video 2", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item where title is not "Video 1"
|
||||
result = store.get_item(title=(Operation.NOT_EQUAL, "Video 1"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
def test_operation_contain(self) -> None:
|
||||
"""Test CONTAIN operation (substring match)."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Python Tutorial Video", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="JavaScript Course", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item with "Python" in title
|
||||
result = store.get_item(title=(Operation.CONTAIN, "Python"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Find item with "Tutorial" in title
|
||||
result = store.get_item(title=(Operation.CONTAIN, "Tutorial"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# No match
|
||||
result = store.get_item(title=(Operation.CONTAIN, "Rust"))
|
||||
assert result is None
|
||||
|
||||
def test_operation_not_contain(self) -> None:
|
||||
"""Test NOT_CONTAIN operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Python Tutorial", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="JavaScript Course", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item that doesn't contain "Python"
|
||||
result = store.get_item(title=(Operation.NOT_CONTAIN, "Python"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
def test_operation_starts_with(self) -> None:
|
||||
"""Test STARTS_WITH operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Tutorial: Python Basics", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="Course: JavaScript", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item starting with "Tutorial"
|
||||
result = store.get_item(title=(Operation.STARTS_WITH, "Tutorial"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Find item starting with "Course"
|
||||
result = store.get_item(title=(Operation.STARTS_WITH, "Course"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
# No match
|
||||
result = store.get_item(title=(Operation.STARTS_WITH, "Video"))
|
||||
assert result is None
|
||||
|
||||
def test_operation_ends_with(self) -> None:
|
||||
"""Test ENDS_WITH operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Learn Python", folder="folder1")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="Learn JavaScript", folder="folder2")
|
||||
item2._id = "id2"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item ending with "Python"
|
||||
result = store.get_item(title=(Operation.ENDS_WITH, "Python"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Find item ending with "JavaScript"
|
||||
result = store.get_item(title=(Operation.ENDS_WITH, "JavaScript"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
# No match
|
||||
result = store.get_item(title=(Operation.ENDS_WITH, "Course"))
|
||||
assert result is None
|
||||
|
||||
def test_operation_greater_than(self) -> None:
|
||||
"""Test GREATER_THAN operation with numeric values."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item1.filesize = 1000
|
||||
item2 = make_item(id="vid2", title="Video 2")
|
||||
item2._id = "id2"
|
||||
item2.filesize = 2000
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item with filesize > 1500
|
||||
result = store.get_item(filesize=(Operation.GREATER_THAN, 1500))
|
||||
assert result is not None
|
||||
assert result.info._id == "id2"
|
||||
|
||||
# Find item with filesize > 500 (should return first match)
|
||||
result = store.get_item(filesize=(Operation.GREATER_THAN, 500))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
def test_operation_less_than(self) -> None:
|
||||
"""Test LESS_THAN operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item1.filesize = 1000
|
||||
item2 = make_item(id="vid2", title="Video 2")
|
||||
item2._id = "id2"
|
||||
item2.filesize = 2000
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
|
||||
# Find item with filesize < 1500
|
||||
result = store.get_item(filesize=(Operation.LESS_THAN, 1500))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
def test_operation_greater_equal(self) -> None:
|
||||
"""Test GREATER_EQUAL operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item1.filesize = 1000
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Test >= with exact match
|
||||
result = store.get_item(filesize=(Operation.GREATER_EQUAL, 1000))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Test >= with less than
|
||||
result = store.get_item(filesize=(Operation.GREATER_EQUAL, 500))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Test >= with greater than
|
||||
result = store.get_item(filesize=(Operation.GREATER_EQUAL, 1500))
|
||||
assert result is None
|
||||
|
||||
def test_operation_less_equal(self) -> None:
|
||||
"""Test LESS_EQUAL operation."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item1.filesize = 1000
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Test <= with exact match
|
||||
result = store.get_item(filesize=(Operation.LESS_EQUAL, 1000))
|
||||
assert result is not None
|
||||
|
||||
# Test <= with greater than
|
||||
result = store.get_item(filesize=(Operation.LESS_EQUAL, 1500))
|
||||
assert result is not None
|
||||
|
||||
# Test <= with less than
|
||||
result = store.get_item(filesize=(Operation.LESS_EQUAL, 500))
|
||||
assert result is None
|
||||
|
||||
def test_mixed_operations(self) -> None:
|
||||
"""Test using multiple operations in a single query."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Python Tutorial", folder="tutorials")
|
||||
item1._id = "id1"
|
||||
item2 = make_item(id="vid2", title="Python Advanced", folder="courses")
|
||||
item2._id = "id2"
|
||||
item3 = make_item(id="vid3", title="JavaScript Basics", folder="tutorials")
|
||||
item3._id = "id3"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
store.put(StubDownload(info=item2))
|
||||
store.put(StubDownload(info=item3))
|
||||
|
||||
# Mix of operation and default (any match returns true)
|
||||
result = store.get_item(title=(Operation.CONTAIN, "Python"), folder="tutorials")
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Mix where first condition matches
|
||||
result = store.get_item(title=(Operation.CONTAIN, "JavaScript"), folder="nonexistent")
|
||||
assert result is not None
|
||||
assert result.info._id == "id3"
|
||||
|
||||
def test_operation_with_none_values(self) -> None:
|
||||
"""Test operations handle None values gracefully."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
item1.description = None
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# CONTAIN with None field should return False
|
||||
result = store.get_item(description=(Operation.CONTAIN, "test"))
|
||||
assert result is None
|
||||
|
||||
# NOT_CONTAIN with None field should return True
|
||||
result = store.get_item(description=(Operation.NOT_CONTAIN, "test"))
|
||||
assert result is not None
|
||||
|
||||
# GREATER_THAN with None should return False
|
||||
result = store.get_item(description=(Operation.GREATER_THAN, 100))
|
||||
assert result is None
|
||||
|
||||
def test_operation_with_invalid_comparisons(self) -> None:
|
||||
"""Test that invalid comparisons are handled gracefully."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Try to compare string with number using > (should return False/None)
|
||||
result = store.get_item(title=(Operation.GREATER_THAN, 100))
|
||||
assert result is None
|
||||
|
||||
def test_operation_backward_compatibility(self) -> None:
|
||||
"""Test that string operation names work for backward compatibility."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Python Tutorial")
|
||||
item1._id = "id1"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Using string operation value
|
||||
result = store.get_item(title=("in", "Python"))
|
||||
assert result is not None
|
||||
assert result.info._id == "id1"
|
||||
|
||||
# Using string for EQUAL
|
||||
result = store.get_item(title=("==", "Python Tutorial"))
|
||||
assert result is not None
|
||||
|
||||
def test_operation_with_nonexistent_field(self) -> None:
|
||||
"""Test operations with fields that don't exist."""
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item1 = make_item(id="vid1", title="Video 1")
|
||||
item1._id = "id1"
|
||||
|
||||
store.put(StubDownload(info=item1))
|
||||
|
||||
# Try to match on non-existent field
|
||||
result = store.get_item(nonexistent_field=(Operation.EQUAL, "value"))
|
||||
assert result is None
|
||||
|
||||
result = store.get_item(nonexistent_field=(Operation.CONTAIN, "value"))
|
||||
assert result is None
|
||||
|
|
|
|||
244
app/tests/test_operations.py
Normal file
244
app/tests/test_operations.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Tests for the generic operations module."""
|
||||
|
||||
|
||||
from app.library.operations import (
|
||||
Operation,
|
||||
filter_items,
|
||||
find_all,
|
||||
find_first,
|
||||
matches,
|
||||
matches_all,
|
||||
matches_any,
|
||||
matches_condition,
|
||||
)
|
||||
|
||||
|
||||
class TestMatchesGeneric:
|
||||
"""Test the generic matches(operation, val1, val2) function."""
|
||||
|
||||
def test_matches_equal(self) -> None:
|
||||
"""Test EQUAL operation."""
|
||||
assert matches(Operation.EQUAL, "test", "test") is True
|
||||
assert matches(Operation.EQUAL, 100, 100) is True
|
||||
assert matches(Operation.EQUAL, "test", "other") is False
|
||||
assert matches(Operation.EQUAL, None, None) is True
|
||||
|
||||
def test_matches_not_equal(self) -> None:
|
||||
"""Test NOT_EQUAL operation."""
|
||||
assert matches(Operation.NOT_EQUAL, "test", "other") is True
|
||||
assert matches(Operation.NOT_EQUAL, 100, 200) is True
|
||||
assert matches(Operation.NOT_EQUAL, "test", "test") is False
|
||||
|
||||
def test_matches_contain(self) -> None:
|
||||
"""Test CONTAIN operation."""
|
||||
assert matches(Operation.CONTAIN, "Python Tutorial", "Python") is True
|
||||
assert matches(Operation.CONTAIN, "Hello World", "World") is True
|
||||
assert matches(Operation.CONTAIN, "test", "xyz") is False
|
||||
assert matches(Operation.CONTAIN, None, "test") is False
|
||||
|
||||
def test_matches_not_contain(self) -> None:
|
||||
"""Test NOT_CONTAIN operation."""
|
||||
assert matches(Operation.NOT_CONTAIN, "Python Tutorial", "Java") is True
|
||||
assert matches(Operation.NOT_CONTAIN, "test", "test") is False
|
||||
assert matches(Operation.NOT_CONTAIN, None, "test") is True
|
||||
|
||||
def test_matches_greater_than(self) -> None:
|
||||
"""Test GREATER_THAN operation."""
|
||||
assert matches(Operation.GREATER_THAN, 100, 50) is True
|
||||
assert matches(Operation.GREATER_THAN, 50, 100) is False
|
||||
assert matches(Operation.GREATER_THAN, 100, 100) is False
|
||||
assert matches(Operation.GREATER_THAN, None, 50) is False
|
||||
assert matches(Operation.GREATER_THAN, 100, None) is False
|
||||
|
||||
def test_matches_less_than(self) -> None:
|
||||
"""Test LESS_THAN operation."""
|
||||
assert matches(Operation.LESS_THAN, 50, 100) is True
|
||||
assert matches(Operation.LESS_THAN, 100, 50) is False
|
||||
assert matches(Operation.LESS_THAN, 100, 100) is False
|
||||
|
||||
def test_matches_greater_equal(self) -> None:
|
||||
"""Test GREATER_EQUAL operation."""
|
||||
assert matches(Operation.GREATER_EQUAL, 100, 50) is True
|
||||
assert matches(Operation.GREATER_EQUAL, 100, 100) is True
|
||||
assert matches(Operation.GREATER_EQUAL, 50, 100) is False
|
||||
|
||||
def test_matches_less_equal(self) -> None:
|
||||
"""Test LESS_EQUAL operation."""
|
||||
assert matches(Operation.LESS_EQUAL, 50, 100) is True
|
||||
assert matches(Operation.LESS_EQUAL, 100, 100) is True
|
||||
assert matches(Operation.LESS_EQUAL, 100, 50) is False
|
||||
|
||||
def test_matches_starts_with(self) -> None:
|
||||
"""Test STARTS_WITH operation."""
|
||||
assert matches(Operation.STARTS_WITH, "Python Tutorial", "Python") is True
|
||||
assert matches(Operation.STARTS_WITH, "Tutorial", "Python") is False
|
||||
assert matches(Operation.STARTS_WITH, None, "test") is False
|
||||
|
||||
def test_matches_ends_with(self) -> None:
|
||||
"""Test ENDS_WITH operation."""
|
||||
assert matches(Operation.ENDS_WITH, "Learn Python", "Python") is True
|
||||
assert matches(Operation.ENDS_WITH, "Python Tutorial", "Python") is False
|
||||
assert matches(Operation.ENDS_WITH, None, "test") is False
|
||||
|
||||
def test_matches_with_string_operation(self) -> None:
|
||||
"""Test backward compatibility with string operations."""
|
||||
assert matches("==", "test", "test") is True
|
||||
assert matches("in", "Python Tutorial", "Python") is True
|
||||
assert matches(">", 100, 50) is True
|
||||
|
||||
def test_matches_with_invalid_operation(self) -> None:
|
||||
"""Test with invalid operation string defaults to EQUAL."""
|
||||
assert matches("invalid_op", "test", "test") is True
|
||||
assert matches("invalid_op", "test", "other") is False
|
||||
|
||||
def test_matches_with_incompatible_types(self) -> None:
|
||||
"""Test that incompatible type comparisons return False."""
|
||||
# Comparing string with number for > operation
|
||||
assert matches(Operation.GREATER_THAN, "text", 100) is False
|
||||
|
||||
|
||||
class TestMatchesCondition:
|
||||
"""Test the matches_condition helper function."""
|
||||
|
||||
def test_matches_condition_simple_value(self) -> None:
|
||||
"""Test with simple value (defaults to EQUAL)."""
|
||||
data = {"title": "Python Tutorial", "size": 1000}
|
||||
assert matches_condition("title", "Python Tutorial", data) is True
|
||||
assert matches_condition("title", "Other", data) is False
|
||||
|
||||
def test_matches_condition_with_operation(self) -> None:
|
||||
"""Test with tuple (operation, value)."""
|
||||
data = {"title": "Python Tutorial", "size": 1000}
|
||||
assert matches_condition("title", (Operation.CONTAIN, "Python"), data) is True
|
||||
assert matches_condition("size", (Operation.GREATER_THAN, 500), data) is True
|
||||
|
||||
def test_matches_condition_missing_key(self) -> None:
|
||||
"""Test with non-existent key."""
|
||||
data = {"title": "Python Tutorial"}
|
||||
assert matches_condition("missing", "value", data) is False
|
||||
|
||||
|
||||
class TestMatchesAll:
|
||||
"""Test matches_all function (AND logic)."""
|
||||
|
||||
def test_matches_all_true(self) -> None:
|
||||
"""Test when all conditions match."""
|
||||
data = {"title": "Python Tutorial", "size": 1000, "status": "active"}
|
||||
assert matches_all(data, title=(Operation.CONTAIN, "Python"), size=(Operation.GREATER_THAN, 500)) is True
|
||||
|
||||
def test_matches_all_false(self) -> None:
|
||||
"""Test when any condition fails."""
|
||||
data = {"title": "Python Tutorial", "size": 1000}
|
||||
assert matches_all(data, title=(Operation.CONTAIN, "Python"), size=(Operation.GREATER_THAN, 2000)) is False
|
||||
|
||||
def test_matches_all_empty_conditions(self) -> None:
|
||||
"""Test with no conditions returns True."""
|
||||
data = {"title": "Python Tutorial"}
|
||||
assert matches_all(data) is True
|
||||
|
||||
|
||||
class TestMatchesAny:
|
||||
"""Test matches_any function (OR logic)."""
|
||||
|
||||
def test_matches_any_true(self) -> None:
|
||||
"""Test when at least one condition matches."""
|
||||
data = {"title": "Python Tutorial", "size": 1000}
|
||||
assert matches_any(data, title=(Operation.CONTAIN, "Java"), size=(Operation.GREATER_THAN, 500)) is True
|
||||
|
||||
def test_matches_any_false(self) -> None:
|
||||
"""Test when no conditions match."""
|
||||
data = {"title": "Python Tutorial", "size": 1000}
|
||||
assert matches_any(data, title="Wrong", status="Wrong") is False
|
||||
|
||||
def test_matches_any_empty_conditions(self) -> None:
|
||||
"""Test with no conditions returns False."""
|
||||
data = {"title": "Python Tutorial"}
|
||||
assert matches_any(data) is False
|
||||
|
||||
|
||||
class TestFilterItems:
|
||||
"""Test filter_items function."""
|
||||
|
||||
def test_filter_items_basic(self) -> None:
|
||||
"""Test basic filtering."""
|
||||
items = [
|
||||
{"title": "Python Tutorial", "size": 1000},
|
||||
{"title": "JavaScript Course", "size": 2000},
|
||||
{"title": "Python Advanced", "size": 1500},
|
||||
]
|
||||
|
||||
result = filter_items(items, title=(Operation.CONTAIN, "Python"))
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Python Tutorial"
|
||||
assert result[1]["title"] == "Python Advanced"
|
||||
|
||||
def test_filter_items_multiple_conditions(self) -> None:
|
||||
"""Test filtering with multiple conditions (AND logic)."""
|
||||
items = [
|
||||
{"title": "Python Tutorial", "size": 1000},
|
||||
{"title": "Python Advanced", "size": 2000},
|
||||
{"title": "JavaScript Course", "size": 1500},
|
||||
]
|
||||
|
||||
result = filter_items(items, title=(Operation.CONTAIN, "Python"), size=(Operation.GREATER_THAN, 1200))
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "Python Advanced"
|
||||
|
||||
def test_filter_items_no_conditions(self) -> None:
|
||||
"""Test that no conditions returns all items."""
|
||||
items = [{"title": "Test 1"}, {"title": "Test 2"}]
|
||||
result = filter_items(items)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestFindFirst:
|
||||
"""Test find_first function."""
|
||||
|
||||
def test_find_first_match(self) -> None:
|
||||
"""Test finding first match."""
|
||||
items = [
|
||||
{"title": "Python Tutorial", "size": 1000},
|
||||
{"title": "Python Advanced", "size": 2000},
|
||||
]
|
||||
|
||||
result = find_first(items, title=(Operation.CONTAIN, "Python"))
|
||||
assert result is not None
|
||||
assert result["title"] == "Python Tutorial"
|
||||
|
||||
def test_find_first_no_match(self) -> None:
|
||||
"""Test when no match is found."""
|
||||
items = [{"title": "Python Tutorial"}]
|
||||
result = find_first(items, title="Nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestFindAll:
|
||||
"""Test find_all function (alias for filter_items)."""
|
||||
|
||||
def test_find_all(self) -> None:
|
||||
"""Test find_all is an alias for filter_items."""
|
||||
items = [
|
||||
{"title": "Python Tutorial", "size": 1000},
|
||||
{"title": "JavaScript Course", "size": 2000},
|
||||
]
|
||||
|
||||
result = find_all(items, title=(Operation.CONTAIN, "Python"))
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "Python Tutorial"
|
||||
|
||||
|
||||
class TestOperationEnum:
|
||||
"""Test Operation enum."""
|
||||
|
||||
def test_operation_values(self) -> None:
|
||||
"""Test operation enum values."""
|
||||
assert Operation.EQUAL.value == "=="
|
||||
assert Operation.NOT_EQUAL.value == "!="
|
||||
assert Operation.CONTAIN.value == "in"
|
||||
assert Operation.GREATER_THAN.value == ">"
|
||||
assert Operation.STARTS_WITH.value == "startswith"
|
||||
|
||||
def test_operation_str(self) -> None:
|
||||
"""Test operation string representation."""
|
||||
assert str(Operation.EQUAL) == "=="
|
||||
assert str(Operation.CONTAIN) == "in"
|
||||
Loading…
Reference in a new issue