66 Update: ACCEPT (legacy), ACCEPT (ts > updated_at), REJECT (ts <= updated_at),
77 NOOP (item gone, no history), UPDATE_HISTORY (item gone, history exists)
88 Add: Idempotent (already on list), always succeeds (not on list)
9+ Validation: zero, negative, and far-future timestamps are rejected by schema
910"""
1011
11- import time
1212from datetime import datetime , timedelta , timezone
1313
1414import pytest
@@ -28,6 +28,16 @@ def _now_ms() -> int:
2828 return _epoch_ms (datetime .now (timezone .utc ))
2929
3030
31+ def _future_ms (seconds : int = 60 ) -> int :
32+ """Return an epoch-ms timestamp ``seconds`` in the future (within validation window)."""
33+ return _epoch_ms (datetime .now (timezone .utc ) + timedelta (seconds = seconds ))
34+
35+
36+ def _past_ms (seconds : int = 3600 ) -> int :
37+ """Return an epoch-ms timestamp ``seconds`` in the past."""
38+ return _epoch_ms (datetime .now (timezone .utc ) - timedelta (seconds = seconds ))
39+
40+
3141def _add_item_to_list (client , shoppinglist_id , item_name = "conflict_item" ):
3242 """Add an item by name and return (item_id, shoppinglist_id)."""
3343 resp = client .post (
@@ -99,8 +109,8 @@ def test_remove_fresh_timestamp_accepts(
99109 user_client_with_household , shoppinglist_id , "fresh_remove"
100110 )
101111
102- # Use a timestamp well in the future so it's guaranteed > updated_at
103- future_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( hours = 1 ) )
112+ # Use a timestamp slightly in the future (within the 5-min validation window)
113+ future_ts = _future_ms ( 60 )
104114 resp = user_client_with_household .delete (
105115 f"/api/shoppinglist/{ shoppinglist_id } /item" ,
106116 json = {"item_id" : item_id , "removed_at" : future_ts },
@@ -119,7 +129,7 @@ def test_remove_stale_timestamp_rejects(
119129 )
120130
121131 # First, update the item's description with a fresh timestamp to bump updated_at
122- fresh_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( hours = 1 ) )
132+ fresh_ts = _future_ms ( 120 )
123133 resp = user_client_with_household .put (
124134 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
125135 json = {
@@ -130,7 +140,7 @@ def test_remove_stale_timestamp_rejects(
130140 assert resp .status_code == 200
131141
132142 # Now try to remove with a stale timestamp (before the update)
133- stale_ts = _epoch_ms ( datetime . now ( timezone . utc ) - timedelta ( hours = 1 ) )
143+ stale_ts = _past_ms ( 3600 )
134144 resp = user_client_with_household .delete (
135145 f"/api/shoppinglist/{ shoppinglist_id } /item" ,
136146 json = {"item_id" : item_id , "removed_at" : stale_ts },
@@ -172,7 +182,7 @@ def test_update_fresh_timestamp_accepts(
172182 item_id = _add_item_to_list (
173183 user_client_with_household , shoppinglist_id , "fresh_update"
174184 )
175- future_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( hours = 1 ) )
185+ future_ts = _future_ms ( 60 )
176186 resp = user_client_with_household .put (
177187 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
178188 json = {"description" : "fresh update" , "client_timestamp" : future_ts },
@@ -190,15 +200,15 @@ def test_update_stale_timestamp_rejects(
190200 )
191201
192202 # First update with a fresh timestamp to bump updated_at
193- fresh_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( hours = 1 ) )
203+ fresh_ts = _future_ms ( 120 )
194204 resp = user_client_with_household .put (
195205 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
196206 json = {"description" : "first update" , "client_timestamp" : fresh_ts },
197207 )
198208 assert resp .status_code == 200
199209
200210 # Now try to update with a stale timestamp
201- stale_ts = _epoch_ms ( datetime . now ( timezone . utc ) - timedelta ( hours = 1 ) )
211+ stale_ts = _past_ms ( 3600 )
202212 resp = user_client_with_household .put (
203213 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
204214 json = {"description" : "stale update" , "client_timestamp" : stale_ts },
@@ -255,7 +265,7 @@ def test_update_item_not_on_list_with_timestamp_updates_history(
255265 assert resp .status_code == 200
256266
257267 # Update with a timestamp in the future (> History DROPPED created_at)
258- future_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( hours = 1 ) )
268+ future_ts = _future_ms ( 60 )
259269 resp = user_client_with_household .put (
260270 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
261271 json = {"description" : "updated in history" , "client_timestamp" : future_ts },
@@ -279,16 +289,15 @@ def test_update_item_not_on_list_stale_timestamp_is_noop(
279289 user_client_with_household , shoppinglist_id , "noop_history_item"
280290 )
281291
282- # Remove the item with a far-future timestamp so History's created_at is in the future
283- far_future = _epoch_ms (datetime .now (timezone .utc ) + timedelta (days = 10 ))
292+ # Remove the item (legacy — no removed_at). History created_at ~ server now.
284293 resp = user_client_with_household .delete (
285294 f"/api/shoppinglist/{ shoppinglist_id } /item" ,
286- json = {"item_id" : item_id , "removed_at" : far_future },
295+ json = {"item_id" : item_id },
287296 )
288297 assert resp .status_code == 200
289298
290- # Try to update with a timestamp that's still in the past relative to the removal
291- past_ts = _epoch_ms ( datetime . now ( timezone . utc ) - timedelta ( hours = 1 ) )
299+ # Try to update with a timestamp in the past (before the removal)
300+ past_ts = _past_ms ( 3600 )
292301 resp = user_client_with_household .put (
293302 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
294303 json = {"description" : "should be ignored" , "client_timestamp" : past_ts },
@@ -376,6 +385,22 @@ def test_update_with_unknown_fields_excluded(
376385 )
377386 assert resp .status_code == 200
378387
388+ def test_bulk_remove_with_unknown_fields_excluded (
389+ self , user_client_with_household , shoppinglist_id
390+ ):
391+ """RemoveItems (bulk) schema should silently drop unknown fields."""
392+ item_id = _add_item_to_list (
393+ user_client_with_household , shoppinglist_id , "bulk_exclude"
394+ )
395+ resp = user_client_with_household .delete (
396+ f"/api/shoppinglist/{ shoppinglist_id } /items" ,
397+ json = {
398+ "items" : [{"item_id" : item_id , "unknown_field" : 42 }],
399+ "top_level_unknown" : True ,
400+ },
401+ )
402+ assert resp .status_code == 200
403+
379404
380405# ===========================================================================
381406# Ordering correctness
@@ -400,16 +425,16 @@ def test_updated_at_set_to_client_timestamp_not_server_time(
400425 user_client_with_household , shoppinglist_id , "ordering_item"
401426 )
402427
403- # Update with a timestamp far in the future
404- far_future = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( days = 5 ) )
428+ # Update with a timestamp at the edge of the validation window
429+ far_future = _future_ms ( 240 )
405430 resp = user_client_with_household .put (
406431 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
407432 json = {"description" : "future update" , "client_timestamp" : far_future },
408433 )
409434 assert resp .status_code == 200
410435
411436 # Try to update with a timestamp that's AFTER now but BEFORE far_future
412- slightly_less = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( days = 3 ) )
437+ slightly_less = _future_ms ( 60 )
413438 resp = user_client_with_household .put (
414439 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
415440 json = {
@@ -437,16 +462,16 @@ def test_remove_respects_client_timestamp_ordering(
437462 user_client_with_household , shoppinglist_id , "remove_order_item"
438463 )
439464
440- # Update with far- future timestamp
441- far_future = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( days = 5 ) )
465+ # Update with future timestamp
466+ far_future = _future_ms ( 240 )
442467 resp = user_client_with_household .put (
443468 f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
444469 json = {"description" : "pinned" , "client_timestamp" : far_future },
445470 )
446471 assert resp .status_code == 200
447472
448473 # Remove with a timestamp between now and far_future
449- mid_ts = _epoch_ms ( datetime . now ( timezone . utc ) + timedelta ( days = 2 ) )
474+ mid_ts = _future_ms ( 60 )
450475 resp = user_client_with_household .delete (
451476 f"/api/shoppinglist/{ shoppinglist_id } /item" ,
452477 json = {"item_id" : item_id , "removed_at" : mid_ts },
@@ -456,3 +481,108 @@ def test_remove_respects_client_timestamp_ordering(
456481 # Item should still be on the list
457482 items = _get_items (user_client_with_household , shoppinglist_id )
458483 assert any (i ["id" ] == item_id for i in items )
484+
485+
486+ # ===========================================================================
487+ # Timestamp validation (schema-level)
488+ # ===========================================================================
489+
490+
491+ class TestTimestampValidation :
492+ """Tests that the schema rejects invalid timestamp values."""
493+
494+ def test_update_rejects_zero_timestamp (
495+ self , user_client_with_household , shoppinglist_id
496+ ):
497+ """client_timestamp=0 should be rejected by schema validation."""
498+ item_id = _add_item_to_list (
499+ user_client_with_household , shoppinglist_id , "val_zero"
500+ )
501+ resp = user_client_with_household .put (
502+ f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
503+ json = {"description" : "test" , "client_timestamp" : 0 },
504+ )
505+ assert resp .status_code == 400
506+
507+ def test_update_rejects_negative_timestamp (
508+ self , user_client_with_household , shoppinglist_id
509+ ):
510+ """Negative client_timestamp should be rejected by schema validation."""
511+ item_id = _add_item_to_list (
512+ user_client_with_household , shoppinglist_id , "val_negative"
513+ )
514+ resp = user_client_with_household .put (
515+ f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
516+ json = {"description" : "test" , "client_timestamp" : - 1000 },
517+ )
518+ assert resp .status_code == 400
519+
520+ def test_update_rejects_far_future_timestamp (
521+ self , user_client_with_household , shoppinglist_id
522+ ):
523+ """client_timestamp far in the future (>5 min) should be rejected."""
524+ item_id = _add_item_to_list (
525+ user_client_with_household , shoppinglist_id , "val_future"
526+ )
527+ far_future = _epoch_ms (datetime .now (timezone .utc ) + timedelta (hours = 1 ))
528+ resp = user_client_with_household .put (
529+ f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
530+ json = {"description" : "test" , "client_timestamp" : far_future },
531+ )
532+ assert resp .status_code == 400
533+
534+ def test_remove_rejects_zero_timestamp (
535+ self , user_client_with_household , shoppinglist_id
536+ ):
537+ """removed_at=0 should be rejected by schema validation."""
538+ item_id = _add_item_to_list (
539+ user_client_with_household , shoppinglist_id , "rm_val_zero"
540+ )
541+ resp = user_client_with_household .delete (
542+ f"/api/shoppinglist/{ shoppinglist_id } /item" ,
543+ json = {"item_id" : item_id , "removed_at" : 0 },
544+ )
545+ assert resp .status_code == 400
546+
547+ def test_remove_rejects_negative_timestamp (
548+ self , user_client_with_household , shoppinglist_id
549+ ):
550+ """Negative removed_at should be rejected by schema validation."""
551+ item_id = _add_item_to_list (
552+ user_client_with_household , shoppinglist_id , "rm_val_neg"
553+ )
554+ resp = user_client_with_household .delete (
555+ f"/api/shoppinglist/{ shoppinglist_id } /item" ,
556+ json = {"item_id" : item_id , "removed_at" : - 999 },
557+ )
558+ assert resp .status_code == 400
559+
560+ def test_remove_rejects_far_future_timestamp (
561+ self , user_client_with_household , shoppinglist_id
562+ ):
563+ """removed_at far in the future (>5 min) should be rejected."""
564+ item_id = _add_item_to_list (
565+ user_client_with_household , shoppinglist_id , "rm_val_future"
566+ )
567+ far_future = _epoch_ms (datetime .now (timezone .utc ) + timedelta (hours = 1 ))
568+ resp = user_client_with_household .delete (
569+ f"/api/shoppinglist/{ shoppinglist_id } /item" ,
570+ json = {"item_id" : item_id , "removed_at" : far_future },
571+ )
572+ assert resp .status_code == 400
573+
574+ def test_valid_timestamp_within_window_accepted (
575+ self , user_client_with_household , shoppinglist_id
576+ ):
577+ """A timestamp within the 5-minute window should be accepted."""
578+ item_id = _add_item_to_list (
579+ user_client_with_household , shoppinglist_id , "val_ok"
580+ )
581+ valid_ts = _future_ms (60 )
582+ resp = user_client_with_household .put (
583+ f"/api/shoppinglist/{ shoppinglist_id } /item/{ item_id } " ,
584+ json = {"description" : "valid" , "client_timestamp" : valid_ts },
585+ )
586+ assert resp .status_code == 200
587+ data = resp .get_json ()
588+ assert data .get ("description" ) == "valid"
0 commit comments