diff --git a/pyproject.toml b/pyproject.toml index 9da3e9b5c..21173d727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dev = [ "types-requests==2.32.4.20260107", "types-urllib3==1.26.25.14", "pydantic-to-typescript2==1.0.6", + "freezegun>=1.5.5", ] [build-system] diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py index 61e216319..68cec02a3 100644 --- a/tests/unit_tests/repository_tests/test_pagination.py +++ b/tests/unit_tests/repository_tests/test_pagination.py @@ -3,11 +3,13 @@ import time from collections import defaultdict from datetime import UTC, datetime, timedelta from random import randint +from unittest.mock import patch from urllib.parse import parse_qsl, urlsplit import pytest from dateutil.relativedelta import relativedelta from fastapi.testclient import TestClient +from freezegun import freeze_time from humps import camelize from pydantic import UUID4 @@ -1589,34 +1591,28 @@ def test_parse_now_passthrough_non_placeholder(): assert result == test_string +@freeze_time("2024-01-15 12:00:00") 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 + # Verify offset is exactly 30 days from the frozen time + dt = dt.replace(tzinfo=UTC) + expected = datetime(2024, 2, 14, 12, 0, 0, tzinfo=UTC) + assert dt == expected +@freeze_time("2024-01-15 12:00:00") 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 + # Verify offset is exactly 1 day from the frozen time + dt = dt.replace(tzinfo=UTC) + expected = datetime(2024, 1, 16, 12, 0, 0, tzinfo=UTC) + assert dt == expected @pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"]) @@ -1636,19 +1632,17 @@ def test_parse_now_with_invalid_amount(invalid_amount): ("S", timedelta(seconds=5)), ], ) +@freeze_time("2024-01-15 12:00:00") 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 + # Verify offset is correct from the frozen time + dt = dt.replace(tzinfo=UTC) + frozen_time = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + expected = frozen_time + offset_delta + assert dt == expected def test_parse_now_with_invalid_unit(): @@ -1662,19 +1656,17 @@ def test_parse_now_with_missing_unit(): @pytest.mark.parametrize("operation,expected_sign", [("+", 1), ("-", -1)]) +@freeze_time("2024-01-15 12:00:00") 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 + # Verify offset direction is correct from the frozen time + dt = dt.replace(tzinfo=UTC) + frozen_time = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + expected = frozen_time + (timedelta(days=5) * expected_sign) + assert dt == expected @pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"]) @@ -1684,80 +1676,25 @@ def test_parse_now_with_invalid_operations(invalid_operation): @pytest.mark.parametrize( - "placeholder,included_dates,excluded_dates", + "placeholder", [ - 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", - ), + pytest.param("$NOW", id="now_current_day"), + pytest.param("$NOW+1d", 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()) - ) + with patch.object(PlaceholderKeyword, "parse_value", wraps=PlaceholderKeyword.parse_value) as mock_parse: + params = { + "page": 1, + "perPage": -1, + "queryFilter": f'id="{unique_user.user_id}" AND lastMade >= "{placeholder}"', + } + response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token) + assert response.status_code == 200 - 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}" + # Verify that the placeholder parsing was called + assert mock_parse.call_count diff --git a/uv.lock b/uv.lock index ec1671a6e..77618c49a 100644 --- a/uv.lock +++ b/uv.lock @@ -443,6 +443,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, ] +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -864,6 +876,7 @@ pgsql = [ dev = [ { name = "coverage" }, { name = "coveragepy-lcov" }, + { name = "freezegun" }, { name = "mkdocs-material" }, { name = "mypy" }, { name = "pre-commit" }, @@ -933,6 +946,7 @@ provides-extras = ["pgsql"] dev = [ { name = "coverage", specifier = "==7.13.2" }, { name = "coveragepy-lcov", specifier = "==0.1.2" }, + { name = "freezegun", specifier = ">=1.5.5" }, { name = "mkdocs-material", specifier = "==9.7.1" }, { name = "mypy", specifier = "==1.19.1" }, { name = "pre-commit", specifier = "==4.5.1" },