mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-02 22:13:11 -05:00
feat: Query relative dates (#6984)
This commit is contained in:
@@ -6,6 +6,7 @@ from random import randint
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi.testclient import TestClient
|
||||
from humps import camelize
|
||||
from pydantic import UUID4
|
||||
@@ -34,6 +35,7 @@ from mealie.schema.response.pagination import (
|
||||
PaginationQuery,
|
||||
)
|
||||
from mealie.schema.user.user import UserRatingUpdate
|
||||
from mealie.services.query_filter.builder import PlaceholderKeyword
|
||||
from mealie.services.seeder.seeder_service import SeederService
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_int, random_string
|
||||
@@ -1567,3 +1569,195 @@ def test_pagination_filter_by_custom_rating(api_client: TestClient, user_tuple:
|
||||
recipes_data = response.json()["items"]
|
||||
assert len(recipes_data) == 1
|
||||
assert recipes_data[0]["id"] == str(recipe_2.id)
|
||||
|
||||
|
||||
def test_parse_now_with_remainder_too_short():
|
||||
with pytest.raises(ValueError, match="Invalid remainder"):
|
||||
PlaceholderKeyword._parse_now("$NOW+d")
|
||||
|
||||
|
||||
def test_parse_now_without_arithmetic():
|
||||
result = PlaceholderKeyword._parse_now("$NOW")
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
|
||||
|
||||
def test_parse_now_passthrough_non_placeholder():
|
||||
test_string = "2024-01-15"
|
||||
result = PlaceholderKeyword._parse_now(test_string)
|
||||
assert result == test_string
|
||||
|
||||
|
||||
def test_parse_now_with_int_amount():
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now("$NOW+30d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is exactly 30 days from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + timedelta(days=30)
|
||||
expected_max = after + timedelta(days=30)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
def test_parse_now_with_single_digit_int():
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now("$NOW+1d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is exactly 1 day from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + timedelta(days=1)
|
||||
expected_max = after + timedelta(days=1)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"])
|
||||
def test_parse_now_with_invalid_amount(invalid_amount):
|
||||
with pytest.raises(ValueError, match="Invalid amount"):
|
||||
PlaceholderKeyword._parse_now(f"$NOW+{invalid_amount}d")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unit,offset_delta",
|
||||
[
|
||||
("y", relativedelta(years=5)),
|
||||
("m", relativedelta(months=5)),
|
||||
("d", timedelta(days=5)),
|
||||
("H", timedelta(hours=5)),
|
||||
("M", timedelta(minutes=5)),
|
||||
("S", timedelta(seconds=5)),
|
||||
],
|
||||
)
|
||||
def test_parse_now_with_valid_units(unit, offset_delta):
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now(f"$NOW+5{unit}")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset is correct from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + offset_delta
|
||||
expected_max = after + offset_delta
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
def test_parse_now_with_invalid_unit():
|
||||
with pytest.raises(ValueError, match="Invalid time unit"):
|
||||
PlaceholderKeyword._parse_now("$NOW+1x")
|
||||
|
||||
|
||||
def test_parse_now_with_missing_unit():
|
||||
with pytest.raises(ValueError, match="Invalid remainder"):
|
||||
PlaceholderKeyword._parse_now("$NOW+1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,expected_sign", [("+", 1), ("-", -1)])
|
||||
def test_parse_now_with_valid_operations(operation, expected_sign):
|
||||
before = datetime.now(UTC)
|
||||
result = PlaceholderKeyword._parse_now(f"$NOW{operation}5d")
|
||||
after = datetime.now(UTC)
|
||||
assert isinstance(result, str)
|
||||
dt = datetime.fromisoformat(result)
|
||||
assert isinstance(dt, datetime)
|
||||
# Verify offset direction is correct from when the function was called
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
expected_min = before + (timedelta(days=5) * expected_sign)
|
||||
expected_max = after + (timedelta(days=5) * expected_sign)
|
||||
assert expected_min <= dt <= expected_max
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"])
|
||||
def test_parse_now_with_invalid_operations(invalid_operation):
|
||||
with pytest.raises(ValueError, match="Invalid operator"):
|
||||
PlaceholderKeyword._parse_now(f"$NOW{invalid_operation}5d")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"placeholder,included_dates,excluded_dates",
|
||||
[
|
||||
pytest.param(
|
||||
"$NOW",
|
||||
["today", "tomorrow"],
|
||||
["yesterday"],
|
||||
id="now_current_day",
|
||||
),
|
||||
pytest.param(
|
||||
"$NOW+1d",
|
||||
["tomorrow", "day_after_tomorrow"],
|
||||
["yesterday", "today"],
|
||||
id="now_plus_one_day",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_e2e_parse_now_placeholder(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
placeholder: str,
|
||||
included_dates: list[str],
|
||||
excluded_dates: list[str],
|
||||
):
|
||||
"""E2E test for parsing $NOW and $NOW+Xd placeholders in datetime filters"""
|
||||
# Create recipes for testing
|
||||
recipe_yesterday = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_today = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_tomorrow = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
recipe_day_after = unique_user.repos.recipes.create(
|
||||
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
|
||||
)
|
||||
|
||||
date_map = {
|
||||
"yesterday": (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"today": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"tomorrow": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"day_after_tomorrow": (datetime.now(UTC) + timedelta(days=2)).strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
recipe_map = {
|
||||
"yesterday": recipe_yesterday,
|
||||
"today": recipe_today,
|
||||
"tomorrow": recipe_tomorrow,
|
||||
"day_after_tomorrow": recipe_day_after,
|
||||
}
|
||||
|
||||
for date_name, recipe in recipe_map.items():
|
||||
date_str = date_map[date_name]
|
||||
datetime_str = f"{date_str}T12:00:00Z"
|
||||
r = api_client.patch(
|
||||
api_routes.recipes_slug_last_made(recipe.slug),
|
||||
json={"timestamp": datetime_str},
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Query using placeholder
|
||||
params = {"page": 1, "perPage": -1, "queryFilter": f'lastMade >= "{placeholder}"'}
|
||||
response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipes_data = response.json()["items"]
|
||||
result_ids = {recipe["id"] for recipe in recipes_data}
|
||||
|
||||
# Verify included and excluded recipes
|
||||
for date_name in included_dates:
|
||||
recipe_id = str(recipe_map[date_name].id)
|
||||
assert recipe_id in result_ids, f"{date_name} should be included with {placeholder}"
|
||||
|
||||
for date_name in excluded_dates:
|
||||
recipe_id = str(recipe_map[date_name].id)
|
||||
assert recipe_id not in result_ids, f"{date_name} should be excluded with {placeholder}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from mealie.schema.response.query_filter import (
|
||||
from mealie.services.query_filter.builder import (
|
||||
LogicalOperator,
|
||||
QueryFilterBuilder,
|
||||
QueryFilterJSON,
|
||||
|
||||
Reference in New Issue
Block a user