Skip to content

Commit 0fe4589

Browse files
authored
Merge pull request #356 from spoo-me/fix/link-scheduling-datetimes
fix(links): normalise scheduling datetimes before comparing
2 parents dfc8e3b + 15d3c82 commit 0fe4589

6 files changed

Lines changed: 114 additions & 5 deletions

File tree

infrastructure/cache/url_cache.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from infrastructure.crypto import verify_password as verify_password_hash
1616
from infrastructure.logging import get_logger
17-
from shared.datetime_utils import to_unix_timestamp
17+
from shared.datetime_utils import to_unix_timestamp, to_unix_timestamp_ceil
1818

1919
if TYPE_CHECKING:
2020
from schemas.models.url import UrlV2Doc
@@ -88,7 +88,8 @@ def from_v2_doc(cls, doc: UrlV2Doc) -> UrlCacheData:
8888
meta_color=doc.meta_tags.color if doc.meta_tags else None,
8989
meta_image_width=im.width if im else None,
9090
meta_image_height=im.height if im else None,
91-
start_time=to_unix_timestamp(doc.starts_at),
91+
# Ceil: a fractional start must never read as live a moment early.
92+
start_time=to_unix_timestamp_ceil(doc.starts_at),
9293
pre_start_url=doc.pre_start_url,
9394
)
9495

services/url_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
resolution_order,
9090
v2_lookup_code,
9191
)
92-
from shared.datetime_utils import parse_datetime, to_unix_timestamp
92+
from shared.datetime_utils import as_aware_utc, parse_datetime, to_unix_timestamp
9393
from shared.emoji_policy import (
9494
canonicalize_emoji_alias,
9595
check_emoji_alias,
@@ -321,6 +321,9 @@ def _check_start_before_expiry(
321321
starts_at: datetime | None, expire_after: datetime | None
322322
) -> None:
323323
"""A link that expires before it starts would never be live."""
324+
# Stored values come back naive-UTC; the request side is aware.
325+
starts_at = as_aware_utc(starts_at)
326+
expire_after = as_aware_utc(expire_after)
324327
if starts_at is not None and expire_after is not None and starts_at >= expire_after:
325328
raise ValidationError(
326329
"starts_at must be before expire_after", field="starts_at"

shared/datetime_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import math
1112
from datetime import datetime, timezone
1213
from typing import Any
1314

@@ -62,6 +63,19 @@ def to_unix_timestamp(dt: datetime | None, default: int | None = None) -> int |
6263
return int(dt.timestamp())
6364

6465

66+
def to_unix_timestamp_ceil(dt: datetime | None) -> int | None:
67+
"""Like ``to_unix_timestamp`` but rounds a fractional second UP.
68+
69+
For a moment something starts: truncating would let it read as begun
70+
up to 999 ms early; rounding up errs on the side of not yet.
71+
"""
72+
if dt is None:
73+
return None
74+
if dt.tzinfo is None:
75+
dt = dt.replace(tzinfo=timezone.utc)
76+
return math.ceil(dt.timestamp())
77+
78+
6579
def as_aware_utc(dt: Any) -> datetime | None:
6680
"""Normalize a stored datetime for comparison — Mongo returns naive
6781
UTC datetimes by default (the client is not ``tz_aware``)."""

tests/unit/infrastructure/test_cache.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,28 @@ def test_from_v2_doc_carries_start_and_fallback(self):
412412
data = UrlCacheData.from_v2_doc(doc)
413413
assert data.start_time == int(starts.replace(tzinfo=timezone.utc).timestamp())
414414
assert data.pre_start_url == "https://example.org/soon"
415+
416+
417+
class TestFromV2DocFractionalStart:
418+
def test_fractional_start_rounds_up_so_the_link_is_never_live_early(self):
419+
from datetime import datetime, timezone
420+
421+
from schemas.models.url import UrlV2Doc
422+
423+
starts = datetime(2030, 6, 1, 12, 0, 0, 900_000, tzinfo=timezone.utc)
424+
doc = UrlV2Doc.from_mongo(
425+
{
426+
"_id": ObjectId(),
427+
"alias": "abc1234",
428+
"domain": "spoo.me",
429+
"created_at": starts,
430+
"long_url": "https://example.com",
431+
"starts_at": starts,
432+
}
433+
)
434+
data = UrlCacheData.from_v2_doc(doc)
435+
whole = int(starts.replace(microsecond=0).timestamp())
436+
assert data.start_time == whole + 1
437+
# At the floor second the link is still not live; one second on it is.
438+
assert data.is_not_yet_live(whole) is True
439+
assert data.is_not_yet_live(whole + 1) is False

tests/unit/services/test_url_service.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3872,7 +3872,9 @@ async def test_cache_hit_past_start_resolves(self):
38723872
async def test_db_path_future_start_raises_after_recache(self):
38733873
svc, url_repo, url_cache = self._svc()
38743874
url_cache.get.return_value = None
3875-
future = datetime.now(timezone.utc) + timedelta(hours=1)
3875+
future = (datetime.now(timezone.utc) + timedelta(hours=1)).replace(
3876+
microsecond=0
3877+
)
38763878
url_repo.find_by_alias.return_value = make_url_v2_doc(starts_at=future)
38773879

38783880
with pytest.raises(NotYetLiveError) as exc:
@@ -4002,6 +4004,36 @@ async def test_update_start_after_existing_expiry_rejected(self):
40024004
URL_OID, UpdateUrlRequest(starts_at=self.FUTURE_TS + 60), USER_OID
40034005
)
40044006

4007+
@pytest.mark.asyncio
4008+
async def test_update_start_against_naive_stored_expiry_is_a_400_not_a_500(self):
4009+
"""Mongo hands back naive UTC; the request side is aware. The order
4010+
check must normalise both or the comparison raises TypeError."""
4011+
svc, url_repo, _url_cache = self._svc()
4012+
from schemas.dto.requests.url import UpdateUrlRequest
4013+
4014+
naive_expiry = datetime.fromtimestamp(self.FUTURE_TS, tz=timezone.utc).replace(
4015+
tzinfo=None
4016+
)
4017+
url_repo.find_by_id.return_value = make_url_v2_doc(expire_after=naive_expiry)
4018+
with pytest.raises(ValidationError, match="before expire_after"):
4019+
await svc.update(
4020+
URL_OID, UpdateUrlRequest(starts_at=self.FUTURE_TS + 60), USER_OID
4021+
)
4022+
4023+
@pytest.mark.asyncio
4024+
async def test_update_expiry_against_naive_stored_start_is_a_400_not_a_500(self):
4025+
svc, url_repo, _url_cache = self._svc()
4026+
from schemas.dto.requests.url import UpdateUrlRequest
4027+
4028+
naive_start = datetime.fromtimestamp(self.FUTURE_TS, tz=timezone.utc).replace(
4029+
tzinfo=None
4030+
)
4031+
url_repo.find_by_id.return_value = make_url_v2_doc(starts_at=naive_start)
4032+
with pytest.raises(ValidationError, match="before expire_after"):
4033+
await svc.update(
4034+
URL_OID, UpdateUrlRequest(expire_after=self.FUTURE_TS - 60), USER_OID
4035+
)
4036+
40054037
@pytest.mark.asyncio
40064038
async def test_update_unchanged_start_is_a_noop(self):
40074039
svc, url_repo, _url_cache = self._svc()

tests/unit/shared/test_datetime_utils.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
import pytest
66

7-
from shared.datetime_utils import convert_to_gmt, parse_datetime
7+
from shared.datetime_utils import (
8+
convert_to_gmt,
9+
parse_datetime,
10+
to_unix_timestamp,
11+
to_unix_timestamp_ceil,
12+
)
813

914
# ---------------------------------------------------------------------------
1015
# shared.datetime_utils — parse_datetime
@@ -69,3 +74,32 @@ def test_parse_datetime_naive_assumed_utc():
6974
)
7075
def test_convert_to_gmt(value, expected):
7176
assert convert_to_gmt(value) == expected
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# shared.datetime_utils — to_unix_timestamp_ceil
81+
# ---------------------------------------------------------------------------
82+
83+
84+
_WHOLE = datetime(2030, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
85+
_WHOLE_TS = int(_WHOLE.timestamp())
86+
87+
88+
@pytest.mark.parametrize(
89+
"value, expected",
90+
[
91+
(None, None),
92+
(_WHOLE, _WHOLE_TS),
93+
(_WHOLE.replace(microsecond=900_000), _WHOLE_TS + 1),
94+
(_WHOLE.replace(microsecond=1), _WHOLE_TS + 1),
95+
# Naive is UTC, the same rule to_unix_timestamp applies.
96+
(_WHOLE.replace(microsecond=900_000, tzinfo=None), _WHOLE_TS + 1),
97+
],
98+
)
99+
def test_to_unix_timestamp_ceil(value, expected):
100+
assert to_unix_timestamp_ceil(value) == expected
101+
102+
103+
def test_ceil_agrees_with_floor_on_whole_seconds():
104+
whole = datetime(2030, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
105+
assert to_unix_timestamp_ceil(whole) == to_unix_timestamp(whole)

0 commit comments

Comments
 (0)