diff --git a/noxfile.py b/noxfile.py index d00df751..44e2e227 100644 --- a/noxfile.py +++ b/noxfile.py @@ -128,7 +128,7 @@ def precommit(session: nox.Session) -> None: "lint", external=True, ) - session.run("pre-commit", *args, external=True) + session.run("pre-commit", *args) if args and args[0] == "install": activate_virtualenv_in_precommit_hooks(session) diff --git a/src/odoo_data_flow/import_threaded.py b/src/odoo_data_flow/import_threaded.py index 3d318164..638d0b2c 100755 --- a/src/odoo_data_flow/import_threaded.py +++ b/src/odoo_data_flow/import_threaded.py @@ -80,11 +80,11 @@ def _read_data_file( try: with open(file_path, encoding=encoding, newline="") as f: reader = csv.reader(f, delimiter=separator) + for _ in range(skip): + next(reader) header = next(reader) if "id" not in header: raise ValueError("Source file must contain an 'id' column.") - for _ in range(skip): - next(reader) return header, list(reader) except FileNotFoundError: log.error(f"Source file not found: {file_path}") diff --git a/tests/test_import_threaded.py b/tests/test_import_threaded.py index 118582a0..2204cc76 100644 --- a/tests/test_import_threaded.py +++ b/tests/test_import_threaded.py @@ -151,6 +151,30 @@ def test_orchestrate_pass_1_does_not_sort_for_o2m( assert unsorted_data[0][0] == "child1" assert unsorted_data[1][0] == "parent1" + @patch("odoo_data_flow.import_threaded._read_data_file") + @patch("odoo_data_flow.import_threaded.conf_lib.get_connection_from_dict") + @patch("odoo_data_flow.import_threaded._run_threaded_pass") + def test_import_data_with_dict_config( + self, + mock_run_pass: MagicMock, + mock_get_conn: MagicMock, + mock_read_file: MagicMock, + ) -> None: + """Test import_data with a dictionary config.""" + mock_read_file.return_value = (["id", "name"], [["xml_a", "A"]]) + mock_run_pass.return_value = ({"id_map": {"xml_a": 101}}, False) + mock_get_conn.return_value.get_model.return_value = MagicMock() + + result, _ = import_data( + config={"host": "localhost"}, + model="res.partner", + unique_id_field="id", + file_csv="dummy.csv", + ) + + assert result is True + mock_get_conn.assert_called_once_with({"host": "localhost"}) + class TestExecuteLoadBatch: """Tests for the _execute_load_batch function's resilience features.""" @@ -661,3 +685,119 @@ def test_recursive_batching_group_col_not_found(self) -> None: mock_log.error.assert_called_once_with( "Grouping column 'non_existent' not found. Cannot use --groupby." ) + + +class TestImportThreadedHelpers: + """Tests for helper functions in import_threaded.py.""" + + def test_read_data_file_with_skip(self, tmp_path: Path) -> None: + """Test that _read_data_file correctly skips lines.""" + source_file = tmp_path / "source.csv" + source_file.write_text("skip1\nskip2\nid,name\n1,Alice") + header, data = _read_data_file(str(source_file), ",", "utf-8", 2) + assert header == ["id", "name"] + assert data == [["1", "Alice"]] + + def test_filter_ignored_columns_malformed_row(self) -> None: + """Test that malformed rows are skipped when filtering.""" + from odoo_data_flow.import_threaded import _filter_ignored_columns + + header = ["id", "name", "age", "city"] + data = [ + ["1", "Alice", "30", "New York"], + ["2", "Bob"], # Malformed row + ] + ignore = ["age"] + new_header, new_data = _filter_ignored_columns(ignore, header, data) + assert new_header == ["id", "name", "city"] + assert new_data == [["1", "Alice", "New York"]] + + def test_create_batch_individually_existing_record(self) -> None: + """Test that existing records are skipped in individual creation mode.""" + mock_model = MagicMock() + existing_record = MagicMock() + existing_record.id = 123 + mock_model.browse.return_value.env.ref.return_value = existing_record + batch_header = ["id", "name"] + batch_lines = [["record1", "Alice"]] + + result = _create_batch_individually( + mock_model, batch_lines, batch_header, 0, {}, [] + ) + + assert result["id_map"] == {"record1": 123} + assert not result["failed_lines"] + mock_model.create.assert_not_called() + + @patch("odoo_data_flow.import_threaded._create_batch_individually") + def test_execute_load_batch_timeout( + self, mock_create_individually: MagicMock + ) -> None: + """Test that a client-side timeout is ignored.""" + from httpx import ReadTimeout + + mock_model = MagicMock() + mock_model.load.side_effect = ReadTimeout("Read timeout") + mock_progress = MagicMock() + thread_state = { + "model": mock_model, + "progress": mock_progress, + "unique_id_field_index": 0, + "ignore_list": [], + } + batch_header = ["id", "name"] + batch_lines = [["rec1", "A"]] + + result = _execute_load_batch(thread_state, batch_lines, batch_header, 1) + + assert result["success"] is True + assert not result["id_map"] + mock_create_individually.assert_not_called() + mock_progress.console.print.assert_any_call( + "[yellow]INFO:[/] Batch 1 processing on server. " + "Continuing to wait for completion..." + ) + + def test_execute_write_batch_fails(self) -> None: + """Test that a failure in a write batch is handled.""" + from odoo_data_flow.import_threaded import _execute_write_batch + + mock_model = MagicMock() + mock_model.write.side_effect = Exception("Access Denied") + thread_state = {"model": mock_model} + batch_writes = ([1, 2], {"name": "New Name"}) + + result = _execute_write_batch(thread_state, batch_writes, 1) + + assert result["success"] is False + assert len(result["failed_writes"]) == 2 + assert result["failed_writes"][0][0] == 1 + assert "Access Denied" in result["failed_writes"][0][2] + + +class TestRunThreadedPass: + """Tests for the _run_threaded_pass orchestrator.""" + + @patch("odoo_data_flow.import_threaded.concurrent.futures.as_completed") + def test_run_threaded_pass_consecutive_failures( + self, mock_as_completed: MagicMock + ) -> None: + """Test that the import is aborted after 50 consecutive failures.""" + from odoo_data_flow.import_threaded import RPCThreadImport, _run_threaded_pass + + rpc_thread = RPCThreadImport(1, Progress(), MagicMock()) + rpc_thread.task_id = rpc_thread.progress.add_task("test") + target_func = MagicMock() + target_func.__name__ = "mock_func" + + # Simulate 50 failed futures + futures = [] + for i in range(50): + future = MagicMock() + future.result.return_value = {"success": False} + futures.append(future) + mock_as_completed.return_value = futures + + with patch.object(rpc_thread, "spawn_thread", return_value=MagicMock()): + _, aborted = _run_threaded_pass(rpc_thread, target_func, [(1, {})], {}) + assert aborted is True diff --git a/tests/test_importer.py b/tests/test_importer.py index 1c379763..0c9cdf16 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -37,6 +37,11 @@ def test_get_fail_filename_recovery_mode(self) -> None: assert "failed" in filename assert any(char.isdigit() for char in filename) + def test_get_fail_filename_normal_mode(self) -> None: + """Tests that _get_fail_filename creates a standard name in normal mode.""" + filename = _get_fail_filename("res.partner", is_fail_run=False) + assert filename == "res_partner_fail.csv" + class TestRunImport: """Tests for the main run_import orchestrator function.""" @@ -203,6 +208,41 @@ def test_run_import_preflight_fails( mock_import_data.assert_not_called() +@patch("odoo_data_flow.importer.import_threaded.import_data") +@patch("odoo_data_flow.importer.Console") +def test_run_import_fail_mode_no_records( + mock_console: MagicMock, mock_import_data: MagicMock, tmp_path: Path +) -> None: + """Test fail mode when the fail file has no records to retry.""" + source_file = tmp_path / "source.csv" + source_file.touch() + fail_file = tmp_path / "res_partner_fail.csv" + fail_file.write_text("id,name\n") # Only a header + + run_import( + config="dummy.conf", + filename=str(source_file), + model="res.partner", + fail=True, + deferred_fields=None, + unique_id_field=None, + no_preflight_checks=True, + headless=True, + worker=1, + batch_size=100, + skip=0, + separator=";", + ignore=None, + context={}, + encoding="utf-8", + o2m=False, + groupby=None, + ) + mock_import_data.assert_not_called() + mock_console.return_value.print.assert_called_once() + assert "No records to retry" in mock_console.return_value.print.call_args[0][0].renderable + + @patch("odoo_data_flow.importer.import_threaded.import_data") @patch("odoo_data_flow.importer._run_preflight_checks", return_value=True) def test_run_import_fail_mode( @@ -284,6 +324,53 @@ def preflight_side_effect(*args: Any, **kwargs: Any) -> bool: assert mock_import_data.call_args.kwargs["file_csv"] == str(sorted_file) +@patch("odoo_data_flow.importer.sort.sort_for_self_referencing") +@patch("odoo_data_flow.importer.import_threaded.import_data") +@patch("odoo_data_flow.importer._run_preflight_checks") +def test_run_import_sort_strategy_already_sorted( + mock_preflight: MagicMock, + mock_import_data: MagicMock, + mock_sort: MagicMock, + tmp_path: Path, +) -> None: + """Test the sort strategy when the file is already sorted.""" + source_file = tmp_path / "source.csv" + source_file.touch() + mock_sort.return_value = True # Indicates file is already sorted + + def preflight_side_effect(*args: Any, **kwargs: Any) -> bool: + kwargs["import_plan"]["strategy"] = "sort_and_one_pass_load" + kwargs["import_plan"]["id_column"] = "id" + kwargs["import_plan"]["parent_column"] = "parent_id" + return True + + mock_preflight.side_effect = preflight_side_effect + mock_import_data.return_value = (True, {"total_records": 1}) + + run_import( + config="dummy.conf", + filename=str(source_file), + model="res.partner", + deferred_fields=None, + unique_id_field=None, + no_preflight_checks=False, + headless=True, + worker=1, + batch_size=100, + skip=0, + fail=False, + separator=";", + ignore=None, + context={}, + encoding="utf-8", + o2m=False, + groupby=None, + ) + mock_sort.assert_called_once() + # Ensure the original file is used + assert mock_import_data.call_args.kwargs["file_csv"] == str(source_file) + + @patch("odoo_data_flow.importer.import_threaded.import_data") def test_run_import_for_migration(mock_import_data: MagicMock) -> None: """Test the run_import_for_migration function.""" @@ -320,3 +407,123 @@ def test_run_import_invalid_context(mock_show_error: MagicMock) -> None: groupby=None, ) mock_show_error.assert_called_once() + + +@patch("odoo_data_flow.importer._show_error_panel") +def test_run_import_invalid_json_type_context(mock_show_error: MagicMock) -> None: + """Test that run_import handles context that is not a JSON dict.""" + run_import( + config="dummy.conf", + filename="dummy.csv", + model="res.partner", + context='["not", "a", "dict"]', # Valid JSON, but not a dict + deferred_fields=None, + unique_id_field=None, + no_preflight_checks=True, + headless=True, + worker=1, + batch_size=100, + skip=0, + fail=False, + separator=";", + ignore=None, + encoding="utf-8", + o2m=False, + groupby=None, + ) + mock_show_error.assert_called_once() + assert "must be a valid JSON dictionary" in mock_show_error.call_args[0][1] + + +@patch("odoo_data_flow.importer.cache.save_id_map") +@patch("odoo_data_flow.importer.relational_import.run_direct_relational_import") +@patch("odoo_data_flow.importer.import_threaded.import_data") +@patch("odoo_data_flow.importer._run_preflight_checks") +def test_run_import_with_relational_strategy( + mock_preflight: MagicMock, + mock_import_data: MagicMock, + mock_run_direct_relational: MagicMock, + mock_save_cache: MagicMock, + tmp_path: Path, +) -> None: + """Test that relational import strategies are called in Pass 2.""" + source_file = tmp_path / "source.csv" + source_file.write_text("id,name,tags\np1,Partner 1,tag1,tag2") + + def preflight_side_effect(*args: Any, **kwargs: Any) -> bool: + kwargs["import_plan"]["strategies"] = { + "tags": {"strategy": "direct_relational_import"} + } + return True + + mock_preflight.side_effect = preflight_side_effect + # Pass 1 successful, returns an id_map + mock_import_data.return_value = (True, {"id_map": {"p1": 1}}) + # Pass 2 (from relational) returns None, so no third import call + mock_run_direct_relational.return_value = None + + run_import( + config=str(tmp_path / "dummy.conf"), + filename=str(source_file), + model="res.partner", + deferred_fields=None, + unique_id_field=None, + no_preflight_checks=False, + headless=True, + worker=1, + batch_size=100, + skip=0, + fail=False, + separator=",", + ignore=None, + context={}, + encoding="utf-8", + o2m=False, + groupby=None, + ) + + assert mock_import_data.call_count == 1 # Only the first pass + mock_run_direct_relational.assert_called_once() + mock_save_cache.assert_called_once() + + +@patch("odoo_data_flow.importer._show_error_panel") +@patch("odoo_data_flow.importer._count_lines", return_value=0) +@patch("odoo_data_flow.importer.import_threaded.import_data") +@patch("odoo_data_flow.importer._run_preflight_checks", return_value=True) +def test_run_import_fails_without_creating_fail_file( + mock_preflight: MagicMock, + mock_import_data: MagicMock, + mock_count_lines: MagicMock, + mock_show_error: MagicMock, + tmp_path: Path, +) -> None: + """Test the failure path where import fails but no fail file is created.""" + source_file = tmp_path / "source.csv" + source_file.touch() + # Simulate import_data returning success=False + mock_import_data.return_value = (False, {}) + + run_import( + config="dummy.conf", + filename=str(source_file), + model="res.partner", + deferred_fields=None, + unique_id_field=None, + no_preflight_checks=False, + headless=True, + worker=1, + batch_size=100, + skip=0, + fail=False, + separator=";", + ignore=None, + context={}, + encoding="utf-8", + o2m=False, + groupby=None, + ) + + mock_import_data.assert_called_once() + mock_show_error.assert_called_once() + assert "Import Failed" in mock_show_error.call_args[0] diff --git a/tests/test_relational_import.py b/tests/test_relational_import.py index 1e577cee..2fc7a47e 100644 --- a/tests/test_relational_import.py +++ b/tests/test_relational_import.py @@ -126,44 +126,83 @@ def test_run_write_tuple_import( assert mock_rel_model.create.call_count == 1 -@patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) -@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") -def test_resolve_related_ids_failure( - mock_get_conn: MagicMock, - mock_load_id_map: MagicMock, -) -> None: - """Test that _resolve_related_ids returns None on failure.""" - mock_get_conn.return_value.get_model.return_value.search_read.return_value = [] - result = relational_import._resolve_related_ids( - "dummy.conf", "res.partner", pl.Series(["p1"]) - ) - assert result is None +class TestResolveRelatedIds: + """Tests for the _resolve_related_ids helper function.""" + @patch("odoo_data_flow.lib.relational_import.cache.load_id_map") + def test_resolve_related_ids_cache_hit(self, mock_load_id_map: MagicMock) -> None: + """Test that _resolve_related_ids returns the cached DataFrame on a hit.""" + cached_df = pl.DataFrame({"external_id": ["cat1"], "db_id": [11]}) + mock_load_id_map.return_value = cached_df + result = relational_import._resolve_related_ids( + "dummy.conf", "res.partner.category", pl.Series(["cat1"]) + ) + assert result is not None + assert_frame_equal(result, cached_df) + mock_load_id_map.assert_called_once_with("dummy.conf", "res.partner.category") -@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_dict") -def test_resolve_related_ids_with_dict(mock_get_conn_dict: MagicMock) -> None: - """Test _resolve_related_ids with a dictionary config.""" - mock_get_conn_dict.return_value.get_model.return_value.search_read.return_value = [] - result = relational_import._resolve_related_ids( - {"host": "localhost"}, "res.partner", pl.Series(["p1.p1"]) - ) - assert result is None + @patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) + @patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") + def test_resolve_related_ids_failure( + self, mock_get_conn: MagicMock, mock_load_id_map: MagicMock + ) -> None: + """Test that _resolve_related_ids returns None on failure.""" + mock_get_conn.return_value.get_model.return_value.search_read.return_value = [] + result = relational_import._resolve_related_ids( + "dummy.conf", "res.partner", pl.Series(["p1.p1"]) + ) + assert result is None + @patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) + @patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") + def test_resolve_related_ids_xml_rpc_error( + self, mock_get_conn: MagicMock, mock_load_id_map: MagicMock + ) -> None: + """Test that an exception during XML-RPC is handled.""" + mock_get_conn.return_value.get_model.return_value.search_read.side_effect = Exception( + "XML-RPC Error" + ) + result = relational_import._resolve_related_ids( + "dummy.conf", "res.partner", pl.Series(["p1.p1"]) + ) + assert result is None -@patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) -@patch( - "odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config", - side_effect=Exception("Connection failed"), -) -def test_resolve_related_ids_connection_error( - mock_get_conn: MagicMock, - mock_load_id_map: MagicMock, -) -> None: - """Test that _resolve_related_ids returns None on connection error.""" - with pytest.raises(Exception, match="Connection failed"): - relational_import._resolve_related_ids( + @patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) + @patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") + def test_resolve_related_ids_invalid_ids( + self, mock_get_conn: MagicMock, mock_load_id_map: MagicMock + ) -> None: + """Test that invalid external IDs are skipped.""" + mock_get_conn.return_value.get_model.return_value.search_read.return_value = [] + result = relational_import._resolve_related_ids( + "dummy.conf", "res.partner", pl.Series(["invalid_id"]) + ) + assert result is None + + @patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_dict") + def test_resolve_related_ids_with_dict(self, mock_get_conn_dict: MagicMock) -> None: + """Test _resolve_related_ids with a dictionary config.""" + mock_get_conn_dict.return_value.get_model.return_value.search_read.return_value = ( + [] + ) + result = relational_import._resolve_related_ids( + {"host": "localhost"}, "res.partner", pl.Series(["p1.p1"]) + ) + assert result is None + + @patch("odoo_data_flow.lib.relational_import.cache.load_id_map", return_value=None) + @patch( + "odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config" + ) + def test_resolve_related_ids_connection_error( + self, mock_get_conn: MagicMock, mock_load_id_map: MagicMock + ) -> None: + """Test that _resolve_related_ids returns None on connection error.""" + mock_get_conn.return_value.is_connected.return_value = False + result = relational_import._resolve_related_ids( "dummy.conf", "res.partner", pl.Series(["p1.p1"]) ) + assert result is None @patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") @@ -205,3 +244,259 @@ def test_run_write_o2m_tuple_import(mock_get_conn: MagicMock) -> None: mock_parent_model.write.assert_called_once_with( [1], {"line_ids": [(0, 0, {"product": "prodA", "qty": 1})]} ) + + +def test_run_direct_relational_import_missing_details() -> None: + """Test direct relational import with missing strategy details.""" + progress = Progress() + task_id = progress.add_task("test") + result = relational_import.run_direct_relational_import( + "dummy.conf", + "res.partner", + "category_id", + {}, # Empty strategy_details + pl.DataFrame(), + {}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is None + + +def test_run_write_tuple_import_missing_details() -> None: + """Test write tuple import with missing strategy details.""" + progress = Progress() + task_id = progress.add_task("test") + result = relational_import.run_write_tuple_import( + "dummy.conf", + "res.partner", + "category_id", + {}, # Empty strategy_details + pl.DataFrame(), + {}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + + +@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") +def test_run_write_o2m_tuple_import_parent_not_found( + mock_get_conn: MagicMock, +) -> None: + """Test o2m tuple import when parent ID is not in the map.""" + progress = Progress() + task_id = progress.add_task("test") + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "line_ids": ['[{"product": "prodA", "qty": 1}]'], + } + ) + mock_parent_model = MagicMock() + mock_get_conn.return_value.get_model.return_value = mock_parent_model + result = relational_import.run_write_o2m_tuple_import( + "dummy.conf", + "res.partner", + "line_ids", + {}, + source_df, + {}, # Empty id_map + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + mock_parent_model.write.assert_not_called() + + +@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") +def test_run_write_o2m_tuple_import_invalid_json( + mock_get_conn: MagicMock, +) -> None: + """Test o2m tuple import with invalid JSON.""" + progress = Progress() + task_id = progress.add_task("test") + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "line_ids": ["not valid json"], + } + ) + mock_parent_model = MagicMock() + mock_get_conn.return_value.get_model.return_value = mock_parent_model + result = relational_import.run_write_o2m_tuple_import( + "dummy.conf", + "res.partner", + "line_ids", + {}, + source_df, + {"p1": 1}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + mock_parent_model.write.assert_not_called() + + +@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") +def test_run_write_o2m_tuple_import_write_fails( + mock_get_conn: MagicMock, +) -> None: + """Test o2m tuple import when the final write fails.""" + progress = Progress() + task_id = progress.add_task("test") + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "line_ids": ['[{"product": "prodA", "qty": 1}]'], + } + ) + mock_parent_model = MagicMock() + mock_parent_model.write.side_effect = Exception("Write failed") + mock_get_conn.return_value.get_model.return_value = mock_parent_model + result = relational_import.run_write_o2m_tuple_import( + "dummy.conf", + "res.partner", + "line_ids", + {}, + source_df, + {"p1": 1}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + + +@patch( + "odoo_data_flow.lib.relational_import._resolve_related_ids", return_value=None +) +def test_run_write_tuple_import_resolve_fails(mock_resolve_ids: MagicMock) -> None: + """Test write tuple import when ID resolution fails.""" + progress = Progress() + task_id = progress.add_task("test") + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "category_id": ["cat1"], + } + ) + strategy_details = { + "relation_table": "res.partner.category.rel", + "relation_field": "partner_id", + "relation": "category_id", + } + result = relational_import.run_write_tuple_import( + "dummy.conf", + "res.partner", + "category_id", + strategy_details, + source_df, + {"p1": 1}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + + +@patch("odoo_data_flow.lib.relational_import.conf_lib.get_connection_from_config") +@patch("odoo_data_flow.lib.relational_import._resolve_related_ids") +def test_run_write_tuple_import_create_fails( + mock_resolve_ids: MagicMock, mock_get_conn: MagicMock +) -> None: + """Verify write tuple import handles create failures.""" + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "category_id": ["cat1"], + } + ) + mock_resolve_ids.return_value = pl.DataFrame( + {"external_id": ["cat1"], "db_id": [11]} + ) + mock_rel_model = MagicMock() + mock_rel_model.create.side_effect = Exception("Create failed") + mock_get_conn.return_value.get_model.return_value = mock_rel_model + + strategy_details = { + "relation_table": "res.partner.category.rel", + "relation_field": "partner_id", + "relation": "category_id", + } + id_map = {"p1": 1} + progress = Progress() + task_id = progress.add_task("test") + + result = relational_import.run_write_tuple_import( + "dummy.conf", + "res.partner", + "category_id", + strategy_details, + source_df, + id_map, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is False + + +@patch( + "odoo_data_flow.lib.relational_import._resolve_related_ids", return_value=None +) +def test_run_direct_relational_import_resolve_fails( + mock_resolve_ids: MagicMock, +) -> None: + """Test direct relational import when ID resolution fails.""" + progress = Progress() + task_id = progress.add_task("test") + source_df = pl.DataFrame( + { + "id": ["p1"], + "name": ["Partner 1"], + "category_id": ["cat1"], + } + ) + strategy_details = { + "relation_table": "res.partner.category.rel", + "relation_field": "partner_id", + "relation": "category_id", + } + result = relational_import.run_direct_relational_import( + "dummy.conf", + "res.partner", + "category_id", + strategy_details, + source_df, + {"p1": 1}, + 1, + 10, + progress, + task_id, + "source.csv", + ) + assert result is None