2222)
2323from samcli .lib .build .build_graph import DEFAULT_DEPENDENCIES_DIR
2424
25- MOCK_RESOURCE_SYNC_TIME = datetime (2023 , 2 , 8 , 12 , 12 , 12 )
25+ MOCK_RESOURCE_SYNC_TIME = datetime (2023 , 2 , 8 , 12 , 12 , 12 , tzinfo = timezone . utc )
2626MOCK_INFRA_SYNC_TIME = datetime .now (timezone .utc )
2727
2828
@@ -141,7 +141,7 @@ def test_sync_state_to_toml(self, dependency_layer, latest_infra_sync_time, reso
141141 self .assertEqual (dependency_layer_toml_field , dependency_layer )
142142
143143 latest_infra_sync_time_toml_field = sync_state_toml_table .get (LATEST_INFRA_SYNC_TIME )
144- self .assertEqual (latest_infra_sync_time_toml_field , latest_infra_sync_time .isoformat ())
144+ self .assertEqual (latest_infra_sync_time_toml_field , latest_infra_sync_time .timestamp ())
145145
146146 resource_sync_states_toml_field = toml_document .get (RESOURCE_SYNC_STATES )
147147 self .assertIsNotNone (resource_sync_states_toml_field )
@@ -155,7 +155,7 @@ def test_sync_state_to_toml(self, dependency_layer, latest_infra_sync_time, reso
155155 resource_sync_state_toml_table .get (HASH ),
156156 )
157157 self .assertEqual (
158- resource_sync_states [resource_sync_state_resource_id ].sync_time .isoformat (),
158+ resource_sync_states [resource_sync_state_resource_id ].sync_time .timestamp (),
159159 resource_sync_state_toml_table .get (SYNC_TIME ),
160160 )
161161
@@ -288,3 +288,203 @@ def test_sync_context_has_no_previous_state_if_file_doesnt_exist(self, patched_r
288288 self .assertIsNone (self .sync_context ._previous_state )
289289 self .assertIsNotNone (self .sync_context ._current_state )
290290 patched_rmtree_if_exists .assert_not_called ()
291+
292+
293+ class TestTimestampParsing (TestCase ):
294+ """Tests for timestamp parsing with various formats and error handling"""
295+
296+ @parameterized .expand (
297+ [
298+ # Valid timestamp formats
299+ (
300+ "timezone_naive" ,
301+ "2025-12-03T22:10:11.916279" ,
302+ {"Resource1" : ("hash1" , "2025-12-03T22:10:35.345701" )},
303+ True ,
304+ ["Resource1" ],
305+ [],
306+ ),
307+ (
308+ "timezone_aware" ,
309+ "2025-12-03T22:10:11.916279+00:00" ,
310+ {"Resource1" : ("hash1" , "2025-12-03T22:10:35.345701+00:00" )},
311+ True ,
312+ ["Resource1" ],
313+ [],
314+ ),
315+ (
316+ "z_suffix" ,
317+ "2024-05-08T15:16:43Z" ,
318+ {"Resource1" : ("hash1" , "2024-05-08T15:16:43Z" )},
319+ True ,
320+ ["Resource1" ],
321+ [],
322+ ),
323+ ("epoch_format" , 1733267411.916279 , {"Resource1" : ("hash1" , 1733267435.345701 )}, True , ["Resource1" ], []),
324+ (
325+ "mixed_formats" ,
326+ "2024-05-08T15:16:43Z" ,
327+ {"Resource1" : ("hash1" , "2025-12-03T22:10:35+00:00" ), "Resource2" : ("hash2" , 1733267440.123456 )},
328+ True ,
329+ ["Resource1" , "Resource2" ],
330+ [],
331+ ),
332+ (
333+ "nested_resource" ,
334+ "2024-05-08T15:16:43Z" ,
335+ {"Parent/Child/Resource" : ("hash" , "2024-05-08T15:16:43Z" )},
336+ True ,
337+ ["Parent/Child/Resource" ],
338+ [],
339+ ),
340+ # Invalid timestamp formats
341+ (
342+ "invalid_resource" ,
343+ 1733267411.916279 ,
344+ {"Valid" : ("hash1" , 1733267435.345701 ), "Invalid" : ("hash2" , "bad-timestamp" )},
345+ True ,
346+ ["Valid" ],
347+ ["Invalid" ],
348+ ),
349+ ("invalid_infra" , "not-timestamp" , {"Valid" : ("hash1" , 1733267435.345701 )}, False , ["Valid" ], []),
350+ (
351+ "multiple_invalid" ,
352+ 1733267411.916279 ,
353+ {
354+ "Valid1" : ("h1" , 1733267435.345701 ),
355+ "Invalid1" : ("h2" , "bad1" ),
356+ "Valid2" : ("h3" , 1733267440.123456 ),
357+ "Invalid2" : ("h4" , "bad2" ),
358+ },
359+ True ,
360+ ["Valid1" , "Valid2" ],
361+ ["Invalid1" , "Invalid2" ],
362+ ),
363+ ("all_invalid" , "bad-infra" , {"Invalid" : ("hash" , "bad-resource" )}, False , [], ["Invalid" ]),
364+ ]
365+ )
366+ def test_timestamp_parsing (
367+ self , test_name , infra_sync_time , resources , has_valid_infra , expected_resources , missing_resources
368+ ):
369+ """Test timestamp parsing for various formats and error handling"""
370+ # Build TOML string
371+ if isinstance (infra_sync_time , str ):
372+ infra_value = f'"{ infra_sync_time } "'
373+ else :
374+ infra_value = str (infra_sync_time )
375+
376+ toml_str = f"""
377+ [sync_state]
378+ dependency_layer = true
379+ latest_infra_sync_time = { infra_value }
380+
381+ [resource_sync_states]
382+ """
383+ for resource_id , (resource_hash , resource_sync_time ) in resources .items ():
384+ if isinstance (resource_sync_time , str ):
385+ sync_time_value = f'"{ resource_sync_time } "'
386+ else :
387+ sync_time_value = str (resource_sync_time )
388+
389+ resource_id_toml = resource_id .replace ("/" , "-" )
390+ toml_str += f"""
391+ [resource_sync_states.{ resource_id_toml } ]
392+ hash = "{ resource_hash } "
393+ sync_time = { sync_time_value }
394+ """
395+
396+ toml_doc = tomlkit .loads (toml_str )
397+
398+ # Mock logger if we expect invalid timestamps
399+ if missing_resources or not has_valid_infra :
400+ with patch ("samcli.commands.sync.sync_context.LOG" ) as mock_log :
401+ sync_state = _toml_document_to_sync_state (toml_doc )
402+ mock_log .warning .assert_called ()
403+ else :
404+ sync_state = _toml_document_to_sync_state (toml_doc )
405+
406+ # Verify infra sync time
407+ if has_valid_infra :
408+ self .assertIsNotNone (sync_state .latest_infra_sync_time )
409+ self .assertEqual (sync_state .latest_infra_sync_time .tzinfo , timezone .utc )
410+ # Verify datetime comparison works
411+ time_diff = datetime .now (timezone .utc ) - sync_state .latest_infra_sync_time
412+ self .assertIsNotNone (time_diff )
413+ else :
414+ self .assertIsNone (sync_state .latest_infra_sync_time )
415+
416+ # Verify expected resources were loaded with correct timezone
417+ for resource_id in expected_resources :
418+ self .assertIn (resource_id , sync_state .resource_sync_states )
419+ self .assertEqual (sync_state .resource_sync_states [resource_id ].sync_time .tzinfo , timezone .utc )
420+
421+ # Verify missing resources were skipped
422+ for resource_id in missing_resources :
423+ self .assertNotIn (resource_id , sync_state .resource_sync_states )
424+
425+
426+ class TestEpochTimestampHandling (TestCase ):
427+ """Tests for epoch timestamp format (new format with backward compatibility)"""
428+
429+ def test_epoch_format_read (self ):
430+ """Test reading epoch timestamps (new format)"""
431+ toml_str = """
432+ [sync_state]
433+ dependency_layer = true
434+ latest_infra_sync_time = 1733267411.916279
435+
436+ [resource_sync_states]
437+
438+ [resource_sync_states.MockResourceId]
439+ hash = "mock-hash"
440+ sync_time = 1733267435.345701
441+ """
442+ toml_doc = tomlkit .loads (toml_str )
443+ sync_state = _toml_document_to_sync_state (toml_doc )
444+
445+ # Verify both are timezone-aware UTC
446+ self .assertEqual (sync_state .latest_infra_sync_time .tzinfo , timezone .utc )
447+ self .assertEqual (sync_state .resource_sync_states ["MockResourceId" ].sync_time .tzinfo , timezone .utc )
448+
449+ def test_epoch_format_write (self ):
450+ """Test that writing always produces epoch, not ISO strings"""
451+ sync_state = SyncState (
452+ dependency_layer = True ,
453+ resource_sync_states = {"TestResource" : ResourceSyncState ("hash123" , datetime .now (timezone .utc ))},
454+ latest_infra_sync_time = datetime .now (timezone .utc ),
455+ )
456+
457+ toml_doc = _sync_state_to_toml_document (sync_state )
458+
459+ # Verify written values are numeric (epoch), not strings (ISO)
460+ self .assertIsInstance (toml_doc ["sync_state" ]["latest_infra_sync_time" ], (int , float ))
461+ self .assertIsInstance (toml_doc ["resource_sync_states" ]["TestResource" ]["sync_time" ], (int , float ))
462+
463+ def test_backward_compatibility_mixed_formats (self ):
464+ """Test that old ISO and new epoch can coexist during migration"""
465+ toml_str = """
466+ [sync_state]
467+ dependency_layer = true
468+ latest_infra_sync_time = 1733267411.916279
469+
470+ [resource_sync_states]
471+
472+ [resource_sync_states.OldResource]
473+ hash = "old-hash"
474+ sync_time = "2025-12-03T22:10:35.345701"
475+
476+ [resource_sync_states.NewResource]
477+ hash = "new-hash"
478+ sync_time = 1733267435.345701
479+ """
480+ toml_doc = tomlkit .loads (toml_str )
481+ sync_state = _toml_document_to_sync_state (toml_doc )
482+
483+ # Both should work and be timezone-aware
484+ self .assertEqual (sync_state .resource_sync_states ["OldResource" ].sync_time .tzinfo , timezone .utc )
485+ self .assertEqual (sync_state .resource_sync_states ["NewResource" ].sync_time .tzinfo , timezone .utc )
486+
487+ # Verify datetime comparison works
488+ current_time = datetime .now (timezone .utc )
489+ time_diff = current_time - sync_state .latest_infra_sync_time
490+ self .assertIsNotNone (time_diff )
0 commit comments