fix: Flaky $NOW tests (#6990)

This commit is contained in:
Michael Genson
2026-02-02 11:24:13 -06:00
committed by GitHub
parent 6255c71609
commit fb8e318739
3 changed files with 52 additions and 100 deletions

View File

@@ -77,6 +77,7 @@ dev = [
"types-requests==2.32.4.20260107", "types-requests==2.32.4.20260107",
"types-urllib3==1.26.25.14", "types-urllib3==1.26.25.14",
"pydantic-to-typescript2==1.0.6", "pydantic-to-typescript2==1.0.6",
"freezegun>=1.5.5",
] ]
[build-system] [build-system]

View File

@@ -3,11 +3,13 @@ import time
from collections import defaultdict from collections import defaultdict
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from random import randint from random import randint
from unittest.mock import patch
from urllib.parse import parse_qsl, urlsplit from urllib.parse import parse_qsl, urlsplit
import pytest import pytest
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from freezegun import freeze_time
from humps import camelize from humps import camelize
from pydantic import UUID4 from pydantic import UUID4
@@ -1589,34 +1591,28 @@ def test_parse_now_passthrough_non_placeholder():
assert result == test_string assert result == test_string
@freeze_time("2024-01-15 12:00:00")
def test_parse_now_with_int_amount(): def test_parse_now_with_int_amount():
before = datetime.now(UTC)
result = PlaceholderKeyword._parse_now("$NOW+30d") result = PlaceholderKeyword._parse_now("$NOW+30d")
after = datetime.now(UTC)
assert isinstance(result, str) assert isinstance(result, str)
dt = datetime.fromisoformat(result) dt = datetime.fromisoformat(result)
assert isinstance(dt, datetime) assert isinstance(dt, datetime)
# Verify offset is exactly 30 days from when the function was called # Verify offset is exactly 30 days from the frozen time
if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC)
dt = dt.replace(tzinfo=UTC) expected = datetime(2024, 2, 14, 12, 0, 0, tzinfo=UTC)
expected_min = before + timedelta(days=30) assert dt == expected
expected_max = after + timedelta(days=30)
assert expected_min <= dt <= expected_max
@freeze_time("2024-01-15 12:00:00")
def test_parse_now_with_single_digit_int(): def test_parse_now_with_single_digit_int():
before = datetime.now(UTC)
result = PlaceholderKeyword._parse_now("$NOW+1d") result = PlaceholderKeyword._parse_now("$NOW+1d")
after = datetime.now(UTC)
assert isinstance(result, str) assert isinstance(result, str)
dt = datetime.fromisoformat(result) dt = datetime.fromisoformat(result)
assert isinstance(dt, datetime) assert isinstance(dt, datetime)
# Verify offset is exactly 1 day from when the function was called # Verify offset is exactly 1 day from the frozen time
if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC)
dt = dt.replace(tzinfo=UTC) expected = datetime(2024, 1, 16, 12, 0, 0, tzinfo=UTC)
expected_min = before + timedelta(days=1) assert dt == expected
expected_max = after + timedelta(days=1)
assert expected_min <= dt <= expected_max
@pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"]) @pytest.mark.parametrize("invalid_amount", ["apple", "abc", "!@#"])
@@ -1636,19 +1632,17 @@ def test_parse_now_with_invalid_amount(invalid_amount):
("S", timedelta(seconds=5)), ("S", timedelta(seconds=5)),
], ],
) )
@freeze_time("2024-01-15 12:00:00")
def test_parse_now_with_valid_units(unit, offset_delta): def test_parse_now_with_valid_units(unit, offset_delta):
before = datetime.now(UTC)
result = PlaceholderKeyword._parse_now(f"$NOW+5{unit}") result = PlaceholderKeyword._parse_now(f"$NOW+5{unit}")
after = datetime.now(UTC)
assert isinstance(result, str) assert isinstance(result, str)
dt = datetime.fromisoformat(result) dt = datetime.fromisoformat(result)
assert isinstance(dt, datetime) assert isinstance(dt, datetime)
# Verify offset is correct from when the function was called # Verify offset is correct from the frozen time
if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC)
dt = dt.replace(tzinfo=UTC) frozen_time = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
expected_min = before + offset_delta expected = frozen_time + offset_delta
expected_max = after + offset_delta assert dt == expected
assert expected_min <= dt <= expected_max
def test_parse_now_with_invalid_unit(): 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)]) @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): def test_parse_now_with_valid_operations(operation, expected_sign):
before = datetime.now(UTC)
result = PlaceholderKeyword._parse_now(f"$NOW{operation}5d") result = PlaceholderKeyword._parse_now(f"$NOW{operation}5d")
after = datetime.now(UTC)
assert isinstance(result, str) assert isinstance(result, str)
dt = datetime.fromisoformat(result) dt = datetime.fromisoformat(result)
assert isinstance(dt, datetime) assert isinstance(dt, datetime)
# Verify offset direction is correct from when the function was called # Verify offset direction is correct from the frozen time
if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC)
dt = dt.replace(tzinfo=UTC) frozen_time = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
expected_min = before + (timedelta(days=5) * expected_sign) expected = frozen_time + (timedelta(days=5) * expected_sign)
expected_max = after + (timedelta(days=5) * expected_sign) assert dt == expected
assert expected_min <= dt <= expected_max
@pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"]) @pytest.mark.parametrize("invalid_operation", ["*", "/", "=", "x"])
@@ -1684,80 +1676,25 @@ def test_parse_now_with_invalid_operations(invalid_operation):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"placeholder,included_dates,excluded_dates", "placeholder",
[ [
pytest.param( pytest.param("$NOW", id="now_current_day"),
"$NOW", pytest.param("$NOW+1d", id="now_plus_one_day"),
["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( def test_e2e_parse_now_placeholder(
api_client: TestClient, api_client: TestClient,
unique_user: TestUser, unique_user: TestUser,
placeholder: str, placeholder: str,
included_dates: list[str],
excluded_dates: list[str],
): ):
"""E2E test for parsing $NOW and $NOW+Xd placeholders in datetime filters""" with patch.object(PlaceholderKeyword, "parse_value", wraps=PlaceholderKeyword.parse_value) as mock_parse:
# Create recipes for testing params = {
recipe_yesterday = unique_user.repos.recipes.create( "page": 1,
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string()) "perPage": -1,
) "queryFilter": f'id="{unique_user.user_id}" AND lastMade >= "{placeholder}"',
recipe_today = unique_user.repos.recipes.create( }
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string()) response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
) assert response.status_code == 200
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 = { # Verify that the placeholder parsing was called
"yesterday": (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%d"), assert mock_parse.call_count
"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}"

14
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "ghp-import" name = "ghp-import"
version = "2.1.0" version = "2.1.0"
@@ -864,6 +876,7 @@ pgsql = [
dev = [ dev = [
{ name = "coverage" }, { name = "coverage" },
{ name = "coveragepy-lcov" }, { name = "coveragepy-lcov" },
{ name = "freezegun" },
{ name = "mkdocs-material" }, { name = "mkdocs-material" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pre-commit" }, { name = "pre-commit" },
@@ -933,6 +946,7 @@ provides-extras = ["pgsql"]
dev = [ dev = [
{ name = "coverage", specifier = "==7.13.2" }, { name = "coverage", specifier = "==7.13.2" },
{ name = "coveragepy-lcov", specifier = "==0.1.2" }, { name = "coveragepy-lcov", specifier = "==0.1.2" },
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "mkdocs-material", specifier = "==9.7.1" }, { name = "mkdocs-material", specifier = "==9.7.1" },
{ name = "mypy", specifier = "==1.19.1" }, { name = "mypy", specifier = "==1.19.1" },
{ name = "pre-commit", specifier = "==4.5.1" }, { name = "pre-commit", specifier = "==4.5.1" },