@@ -161,7 +161,9 @@ def test_execute_batch_handles_json_decode_error(self) -> None:
161161 )
162162
163163 # 2. Action
164- with patch ("odoo_data_flow.export_threaded.log.error" ) as mock_log_error :
164+ with patch (
165+ "odoo_data_flow.export_threaded.log.error"
166+ ) as mock_log_error :
165167 result = thread ._execute_batch ([1 ], 1 )
166168
167169 # 3. Assert
@@ -454,7 +456,9 @@ def test_export_handles_memory_error_fallback(
454456
455457 # Verify the final file has all data from the successful retries
456458 on_disk_df = pl .read_csv (output_file , separator = ";" )
457- expected_df = pl .DataFrame ({"id" : [1 , 2 , 3 , 4 ], "name" : ["A" , "B" , "C" , "D" ]})
459+ expected_df = pl .DataFrame (
460+ {"id" : [1 , 2 , 3 , 4 ], "name" : ["A" , "B" , "C" , "D" ]}
461+ )
458462 assert_frame_equal (on_disk_df .sort ("id" ), expected_df .sort ("id" ))
459463
460464 def test_export_handles_empty_batch_result (
@@ -524,7 +528,9 @@ def test_export_handles_permanent_worker_failure(
524528 on_disk_df = pl .read_csv (output_file , separator = ";" )
525529 assert len (on_disk_df ) == 1
526530
527- def test_initialize_export_connection_error (self , mock_conf_lib : MagicMock ) -> None :
531+ def test_initialize_export_connection_error (
532+ self , mock_conf_lib : MagicMock
533+ ) -> None :
528534 """Tests that the function handles connection errors gracefully."""
529535 mock_conf_lib .side_effect = Exception ("Connection Refused" )
530536
@@ -614,7 +620,9 @@ def test_process_export_batches_empty_result(
614620 if result is not None :
615621 assert result .is_empty ()
616622
617- def test_process_export_batches_no_dfs_with_output (self , tmp_path : Path ) -> None :
623+ def test_process_export_batches_no_dfs_with_output (
624+ self , tmp_path : Path
625+ ) -> None :
618626 """Test _process_export_batches with no dataframes and an output file."""
619627 mock_rpc_thread = MagicMock ()
620628 mock_rpc_thread .futures = []
@@ -640,7 +648,9 @@ def test_process_export_batches_no_dfs_with_output(self, tmp_path: Path) -> None
640648 assert result .is_empty ()
641649 mock_write_csv .assert_called_once ()
642650
643- def test_export_relational_raw_id_success (self , mock_conf_lib : MagicMock ) -> None :
651+ def test_export_relational_raw_id_success (
652+ self , mock_conf_lib : MagicMock
653+ ) -> None :
644654 """Test Relational Raw id.
645655
646656 Tests that requesting a relational field with '/.id' triggers read mode
@@ -708,7 +718,9 @@ def test_export_hybrid_mode_success(self, mock_conf_lib: MagicMock) -> None:
708718 }
709719
710720 # 2. Mock the primary read() call
711- mock_model .read .return_value = [{"id" : 10 , "parent_id" : (5 , "Parent Category" )}]
721+ mock_model .read .return_value = [
722+ {"id" : 10 , "parent_id" : (5 , "Parent Category" )}
723+ ]
712724
713725 # 3. Mock the secondary XML ID lookup on 'ir.model.data'
714726 mock_ir_model_data = MagicMock ()
@@ -737,7 +749,9 @@ def test_export_hybrid_mode_success(self, mock_conf_lib: MagicMock) -> None:
737749 )
738750 assert_frame_equal (result_df , expected_df )
739751
740- def test_export_id_in_export_data_mode (self , mock_conf_lib : MagicMock ) -> None :
752+ def test_export_id_in_export_data_mode (
753+ self , mock_conf_lib : MagicMock
754+ ) -> None :
741755 """Test export id in export data.
742756
743757 Tests that in export_data mode, the 'id' field correctly resolves
@@ -824,7 +838,9 @@ def test_export_auto_enables_read_mode_for_selection_field(
824838
825839 # --- Assert ---
826840 _init_args , init_kwargs = mock_rpc_thread_class .call_args
827- assert init_kwargs .get ("technical_names" ) is True , "Read mode was not triggered"
841+ assert init_kwargs .get ("technical_names" ) is True , (
842+ "Read mode was not triggered"
843+ )
828844
829845 assert result_df is not None
830846 expected_df = pl .DataFrame ({"name" : ["Test Record" ], "state" : ["done" ]})
@@ -874,10 +890,14 @@ def test_export_auto_enables_read_mode_for_binary_field(
874890
875891 # --- Assert ---
876892 _init_args , init_kwargs = mock_rpc_thread_class .call_args
877- assert init_kwargs .get ("technical_names" ) is True , "Read mode was not triggered"
893+ assert init_kwargs .get ("technical_names" ) is True , (
894+ "Read mode was not triggered"
895+ )
878896
879897 assert result_df is not None
880- expected_df = pl .DataFrame ({"name" : ["test.zip" ], "datas" : ["UEsDBAoAAAAA..." ]})
898+ expected_df = pl .DataFrame (
899+ {"name" : ["test.zip" ], "datas" : ["UEsDBAoAAAAA..." ]}
900+ )
881901 assert_frame_equal (result_df , expected_df )
882902
883903 @patch ("odoo_data_flow.export_threaded.concurrent.futures.as_completed" )
@@ -1006,3 +1026,175 @@ def test_export_main_record_xml_id_enrichment(
10061026
10071027 # Sort by name to ensure consistent order for comparison
10081028 assert_frame_equal (result_df .sort ("name" ), expected_df .sort ("name" ))
1029+
1030+ def test_execute_batch_single_record_failure (self ) -> None :
1031+ """Test _execute_batch_with_retry handling when single record fails."""
1032+ mock_model = MagicMock ()
1033+ mock_connection = MagicMock ()
1034+ fields_info = {"id" : {"type" : "integer" }}
1035+ thread = RPCThreadExport (
1036+ 1 ,
1037+ mock_connection ,
1038+ mock_model ,
1039+ ["id" ],
1040+ fields_info ,
1041+ technical_names = True ,
1042+ )
1043+
1044+ # Test the else branch: when there"s only 1 ID and it fails permanently
1045+ # This should set has_failures = True and return empty lists
1046+ with patch .object (thread , "_execute_batch" ) as mock_execute_batch :
1047+ # Configure to raise an exception that will cause permanent failure
1048+ error = httpx .ReadTimeout ("Network timeout" , request = None )
1049+ mock_execute_batch .side_effect = error
1050+
1051+ result_data , processed_ids = thread ._execute_batch_with_retry (
1052+ [42 ], "single_batch" , error
1053+ )
1054+
1055+ # Should return empty lists
1056+ assert result_data == []
1057+ assert processed_ids == []
1058+ # has_failures should be set to True
1059+ assert thread .has_failures is True
1060+
1061+ def test_resume_existing_session_missing_all_ids (
1062+ self , tmp_path : Path
1063+ ) -> None :
1064+ """Test _resume_existing_session when all_ids.json is missing."""
1065+ from odoo_data_flow .export_threaded import _resume_existing_session
1066+
1067+ # Create session directory without all_ids.json
1068+ session_dir = tmp_path / "session_dir"
1069+ session_dir .mkdir ()
1070+
1071+ # Don"t create all_ids.json file
1072+
1073+ session_id = "test_session"
1074+
1075+ ids_to_export , total_count = _resume_existing_session (
1076+ session_dir , session_id
1077+ )
1078+
1079+ # Should return empty list since all_ids.json is missing
1080+ assert ids_to_export == []
1081+ assert total_count == 0
1082+
1083+ def test_resume_existing_session_with_completed_ids (
1084+ self , tmp_path : Path
1085+ ) -> None :
1086+ """Test _resume_existing_session with existing completed IDs."""
1087+ import json
1088+
1089+ from odoo_data_flow .export_threaded import _resume_existing_session
1090+
1091+ # Create session directory with both files
1092+ session_dir = tmp_path / "session_dir"
1093+ session_dir .mkdir ()
1094+
1095+ # Create all_ids.json with all record IDs
1096+ all_ids = [1 , 2 , 3 , 4 , 5 ]
1097+ all_ids_file = session_dir / "all_ids.json"
1098+ with open (all_ids_file , "w" ) as f :
1099+ json .dump (all_ids , f )
1100+
1101+ # Create completed_ids.txt with some completed records
1102+ completed_ids_file = session_dir / "completed_ids.txt"
1103+ with open (completed_ids_file , "w" ) as f :
1104+ f .write ("1\n " )
1105+ f .write ("3\n " )
1106+ f .write ("5\n " )
1107+
1108+ session_id = "test_session"
1109+
1110+ ids_to_export , total_count = _resume_existing_session (
1111+ session_dir , session_id
1112+ )
1113+
1114+ # Should return only uncompleted IDs (2, 4)
1115+ assert sorted (ids_to_export ) == [2 , 4 ]
1116+ assert total_count == 5 # Total was 5
1117+
1118+ def test_execute_batch_successful_split_retry (self ) -> None :
1119+ """Test _execute_batch_with_retry with successful batch split and retry."""
1120+ import httpx
1121+
1122+ from odoo_data_flow .export_threaded import RPCThreadExport
1123+
1124+ mock_model = MagicMock ()
1125+ mock_connection = MagicMock ()
1126+ fields_info = {"id" : {"type" : "integer" }}
1127+ thread = RPCThreadExport (
1128+ 1 ,
1129+ mock_connection ,
1130+ mock_model ,
1131+ ["id" ],
1132+ fields_info ,
1133+ technical_names = True ,
1134+ )
1135+
1136+ # Mock _execute_batch to simulate successful batch split processing
1137+ # When called with [1, 2, 3, 4], it returns two successful halves
1138+ with patch .object (thread , "_execute_batch" ) as mock_execute_batch :
1139+ # First call for first half [1, 2] returns success
1140+ # Second call for second half [3, 4] returns success
1141+ mock_execute_batch .side_effect = [
1142+ ([{"id" : 1 }, {"id" : 2 }], [1 , 2 ]), # First half results
1143+ ([{"id" : 3 }, {"id" : 4 }], [3 , 4 ]), # Second half results
1144+ ]
1145+
1146+ # Call _execute_batch_with_retry with a batch that will be split
1147+ result_data , processed_ids = thread ._execute_batch_with_retry (
1148+ [1 , 2 , 3 , 4 ],
1149+ "test_batch" ,
1150+ httpx .ReadTimeout ("Network timeout" , request = None ),
1151+ )
1152+
1153+ # Should have been called twice (once for each half)
1154+ assert mock_execute_batch .call_count == 2
1155+
1156+ # Check the calls
1157+ calls = mock_execute_batch .call_args_list
1158+ first_call_args = calls [0 ][0 ] # First call args
1159+ second_call_args = calls [1 ][0 ] # Second call args
1160+
1161+ # Should split [1,2,3,4] into [1,2] and [3,4]
1162+ assert first_call_args [0 ] == [1 , 2 ] # First half
1163+ assert (
1164+ first_call_args [1 ] == "test_batch-a"
1165+ ) # First half batch number
1166+ assert second_call_args [0 ] == [3 , 4 ] # Second half
1167+ assert (
1168+ second_call_args [1 ] == "test_batch-b"
1169+ ) # Second half batch number
1170+
1171+ # Results should be combined
1172+ expected_data = [{"id" : 1 }, {"id" : 2 }, {"id" : 3 }, {"id" : 4 }]
1173+ expected_ids = [1 , 2 , 3 , 4 ]
1174+
1175+ assert result_data == expected_data
1176+ assert processed_ids == expected_ids
1177+
1178+ def test_enrich_main_df_with_xml_ids_missing_id_column (self ) -> None :
1179+ """Test _enrich_main_df_with_xml_ids when ".id" column is missing."""
1180+ import polars as pl
1181+
1182+ from odoo_data_flow .export_threaded import _enrich_main_df_with_xml_ids
1183+
1184+ # Create DataFrame without ".id" column
1185+ df_without_id = pl .DataFrame (
1186+ {
1187+ "name" : ["Test" , "Another" ],
1188+ "value" : [100 , 200 ],
1189+ }
1190+ )
1191+
1192+ mock_connection = MagicMock ()
1193+ model_name = "res.partner"
1194+
1195+ result_df = _enrich_main_df_with_xml_ids (
1196+ df_without_id , mock_connection , model_name
1197+ )
1198+
1199+ # DataFrame should remain unchanged if ".id" column is missing
1200+ assert result_df .equals (df_without_id )
0 commit comments