Skip to content

Commit ac5ebe6

Browse files
bosdbosd
andauthored
Improve test coverage for import/export flows
* Improve test coverage for import flows - Add test_run_import_fail_mode_no_records: Test fail mode when fail file has no records - Add test_run_import_sort_strategy_already_sorted: Test sort strategy with already sorted file - Add test_run_import_invalid_json_type_context: Test handling of non-dict JSON context - Add test_run_import_with_relational_strategy: Test relational import strategies in Pass 2 - Add test_run_import_fails_without_creating_fail_file: Test failure path without fail file creation These new tests improve coverage for edge cases and error handling in the import flows. * Fixup failling tests * Fix typeguard issues in import tests and improve type safety - Fix typeguard error in test_run_import_invalid_json_type_context by using proper typing - Improve type safety in run_import function by separating json.loads() result from typed variable\n- Ensure all 385 tests pass with typeguard enabled - Maintain all existing functionality while improving code quality * Improve coverage export_threaded --------- Co-authored-by: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me>
1 parent 5f33c27 commit ac5ebe6

File tree

3 files changed

+375
-13
lines changed

3 files changed

+375
-13
lines changed

src/odoo_data_flow/importer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@ def run_import( # noqa: C901
113113
parsed_context: dict[str, Any]
114114
if isinstance(context, str):
115115
try:
116-
parsed_context = json.loads(context)
117-
if not isinstance(parsed_context, dict):
116+
loaded_context = json.loads(context)
117+
if not isinstance(loaded_context, dict):
118118
raise TypeError
119+
parsed_context = loaded_context
119120
except (json.JSONDecodeError, TypeError):
120121
_show_error_panel(
121122
"Invalid Context",

tests/test_export_threaded.py

Lines changed: 202 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)