Skip to content

Commit eaeb6cd

Browse files
committed
test(spp_import_match): increase test coverage to 90%+ with 19 new tests
Add test_base_load.py (8 tests) covering Base.load() override paths: no usable rules, overwrite true/false, no match creates, mixed records, import_match_ids context filtering, id field append, match counts tracking. Add test_base_import_methods.py (8 tests) covering SPPBaseImport helpers: CSV attachment roundtrip, custom options, extract chunks, import_one_chunk success/error, ImportValidationError, execute_import with match_ids context. Add 3 tests to test_import_match_model.py: onchange_field_id skips relational fields, match_find sub_field matching, match_find multiple rule combinations.
1 parent 9edfb31 commit eaeb6cd

File tree

4 files changed

+374
-0
lines changed

4 files changed

+374
-0
lines changed

spp_import_match/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
from . import test_import_match_model
33
from . import test_base_write
44
from . import test_queue_job
5+
from . import test_base_load
6+
from . import test_base_import_methods
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
3+
from unittest.mock import patch
4+
5+
from odoo.tests import TransactionCase, tagged
6+
7+
from odoo.addons.queue_job.exception import FailedJobError
8+
9+
from ..models.base_import import ImportValidationError
10+
11+
OPTIONS = {
12+
"import_skip_records": [],
13+
"import_set_empty_fields": [],
14+
"fallback_values": {},
15+
"name_create_enabled_fields": {},
16+
"encoding": "utf-8",
17+
"separator": ",",
18+
"quoting": '"',
19+
"date_format": "",
20+
"datetime_format": "",
21+
"float_thousand_separator": ",",
22+
"float_decimal_separator": ".",
23+
"advanced": True,
24+
"has_headers": True,
25+
"keep_matches": False,
26+
"limit": 2000,
27+
"sheets": [],
28+
"sheet": "",
29+
"skip": 0,
30+
"tracking_disable": True,
31+
}
32+
33+
34+
@tagged("post_install", "-at_install")
35+
class TestBaseImportMethods(TransactionCase):
36+
"""Test SPPBaseImport helper methods."""
37+
38+
@classmethod
39+
def setUpClass(cls):
40+
super().setUpClass()
41+
cls.csv_options = {
42+
"separator": ",",
43+
"quoting": '"',
44+
"encoding": "utf-8",
45+
}
46+
cls.import_record = cls.env["base_import.import"].create(
47+
{
48+
"res_model": "res.partner",
49+
"file_name": "test.csv",
50+
}
51+
)
52+
53+
def test_csv_attachment_roundtrip(self):
54+
"""_create_csv_attachment + _read_csv_attachment round-trip."""
55+
fields_in = ["name", "email"]
56+
data_in = [["Alice", "alice@test.com"], ["Bob", "bob@test.com"]]
57+
attachment = self.import_record._create_csv_attachment(fields_in, data_in, self.csv_options, "roundtrip.csv")
58+
self.assertTrue(attachment.id)
59+
self.assertEqual(attachment.name, "roundtrip.csv")
60+
fields_out, data_out = self.import_record._read_csv_attachment(attachment, self.csv_options)
61+
self.assertEqual(fields_out, fields_in)
62+
self.assertEqual(len(data_out), 2)
63+
self.assertEqual(data_out[0], ["Alice", "alice@test.com"])
64+
self.assertEqual(data_out[1], ["Bob", "bob@test.com"])
65+
66+
def test_csv_attachment_custom_options(self):
67+
"""_create/_read_csv_attachment with custom separator/quoting/encoding."""
68+
custom_options = {
69+
"separator": ";",
70+
"quoting": "'",
71+
"encoding": "latin-1",
72+
}
73+
fields_in = ["name", "email"]
74+
data_in = [["TestAccent", "accent@test.com"]]
75+
attachment = self.import_record._create_csv_attachment(fields_in, data_in, custom_options, "custom.csv")
76+
fields_out, data_out = self.import_record._read_csv_attachment(attachment, custom_options)
77+
self.assertEqual(fields_out, fields_in)
78+
self.assertEqual(data_out[0][0], "TestAccent")
79+
80+
def test_extract_chunks_basic(self):
81+
"""_extract_chunks splits data into chunks >= chunk_size."""
82+
model_obj = self.env["res.partner"]
83+
fields = ["name", "email"]
84+
data = [[f"P{i}", f"p{i}@test.com"] for i in range(7)]
85+
chunks = list(self.env["base_import.import"]._extract_chunks(model_obj, fields, data, 3))
86+
self.assertTrue(len(chunks) > 1)
87+
# All rows should be covered
88+
all_rows = set()
89+
for start, end in chunks:
90+
for r in range(start, end + 1):
91+
all_rows.add(r)
92+
self.assertEqual(all_rows, set(range(7)))
93+
94+
def test_extract_chunks_small_data(self):
95+
"""_extract_chunks with data smaller than chunk_size yields one chunk."""
96+
model_obj = self.env["res.partner"]
97+
fields = ["name", "email"]
98+
data = [["A", "a@test.com"], ["B", "b@test.com"]]
99+
chunks = list(self.env["base_import.import"]._extract_chunks(model_obj, fields, data, 100))
100+
self.assertEqual(len(chunks), 1)
101+
self.assertEqual(chunks[0], (0, 1))
102+
103+
def test_import_one_chunk_success(self):
104+
"""_import_one_chunk loads data successfully."""
105+
attachment = self.import_record._create_csv_attachment(
106+
["name"], [["ChunkSuccess99xyz"]], self.csv_options, "chunk.csv"
107+
)
108+
result = self.import_record._import_one_chunk("res.partner", attachment, self.csv_options, {})
109+
errors = [m for m in result["messages"] if m.get("type") == "error"]
110+
self.assertFalse(errors)
111+
partner = self.env["res.partner"].search([("name", "=", "ChunkSuccess99xyz")])
112+
self.assertEqual(len(partner), 1)
113+
114+
def test_import_one_chunk_error(self):
115+
"""_import_one_chunk raises FailedJobError on load errors."""
116+
attachment = self.import_record._create_csv_attachment(["name"], [["ErrTest"]], self.csv_options, "err.csv")
117+
error_result = {
118+
"messages": [{"type": "error", "message": "Test error"}],
119+
"ids": [],
120+
}
121+
with patch.object(type(self.env["res.partner"]), "load", return_value=error_result):
122+
with self.assertRaises(FailedJobError):
123+
self.import_record._import_one_chunk("res.partner", attachment, self.csv_options, {})
124+
125+
def test_import_validation_error_attributes(self):
126+
"""ImportValidationError stores field, type, and message attrs."""
127+
err = ImportValidationError("Bad value", field="name", error_type="warning", field_type="char")
128+
self.assertEqual(str(err), "Bad value")
129+
self.assertEqual(err.message, "Bad value")
130+
self.assertEqual(err.type, "warning")
131+
self.assertEqual(err.field_path, ["name"])
132+
self.assertEqual(err.field_type, "char")
133+
self.assertFalse(err.record)
134+
self.assertTrue(err.not_matching_error)
135+
# Test defaults
136+
err2 = ImportValidationError("Default error")
137+
self.assertEqual(err2.type, "error")
138+
self.assertFalse(err2.field_path)
139+
self.assertIsNone(err2.field_type)
140+
141+
def test_execute_import_with_match_ids_passes_context(self):
142+
"""execute_import passes import_match_ids and overwrite_match to context."""
143+
res_partner_model = self.env["ir.model"].search([("model", "=", "res.partner")])
144+
name_field = self.env["ir.model.fields"].search(
145+
[("name", "=", "name"), ("model_id", "=", res_partner_model.id)]
146+
)
147+
self.env["res.partner"].create({"name": "ExecMatchTest99xyz", "email": "exec@test.com"})
148+
match = self.env["spp.import.match"].create({"model_id": res_partner_model.id, "overwrite_match": True})
149+
self.env["spp.import.match.fields"].create({"field_id": name_field.id, "match_id": match.id})
150+
import_rec = self.env["base_import.import"].create(
151+
{
152+
"res_model": "res.partner",
153+
"file": b"name,email\nExecMatchTest99xyz,updated@test.com",
154+
"file_name": "test_exec.csv",
155+
"file_type": "csv",
156+
}
157+
)
158+
options = dict(OPTIONS)
159+
options["import_match_ids"] = [match.id]
160+
options["overwrite_match"] = True
161+
result = import_rec.execute_import(["name", "email"], [], options, dryrun=True)
162+
self.assertIn("import_match_counts", result)
163+
self.assertEqual(result["import_match_counts"]["overwritten"], 1)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo.tests import TransactionCase, tagged
4+
5+
from ..models.base import _import_match_local
6+
7+
8+
@tagged("post_install", "-at_install")
9+
class TestBaseLoad(TransactionCase):
10+
"""Test the Base.load() override with import matching."""
11+
12+
@classmethod
13+
def setUpClass(cls):
14+
super().setUpClass()
15+
cls.res_partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")])
16+
cls.name_field = cls.env["ir.model.fields"].search(
17+
[("name", "=", "name"), ("model_id", "=", cls.res_partner_model.id)]
18+
)
19+
cls.email_field = cls.env["ir.model.fields"].search(
20+
[("name", "=", "email"), ("model_id", "=", cls.res_partner_model.id)]
21+
)
22+
23+
def _create_match_rule(self, field_ids_data, overwrite=True):
24+
"""Helper to create a match rule with fields."""
25+
match = self.env["spp.import.match"].create(
26+
{
27+
"model_id": self.res_partner_model.id,
28+
"overwrite_match": overwrite,
29+
}
30+
)
31+
for data in field_ids_data:
32+
data["match_id"] = match.id
33+
self.env["spp.import.match.fields"].create(data)
34+
return match
35+
36+
def test_load_no_usable_rules(self):
37+
"""When no usable rules exist, load() passes through to super()."""
38+
result = self.env["res.partner"].load(
39+
["name", "email"],
40+
[["NoRuleTest99xyz", "norule@test.com"]],
41+
)
42+
self.assertFalse(result["messages"])
43+
partner = self.env["res.partner"].search([("name", "=", "NoRuleTest99xyz")])
44+
self.assertEqual(len(partner), 1)
45+
46+
def test_load_match_overwrite_true(self):
47+
"""Match found + overwrite=True -> record updated."""
48+
partner = self.env["res.partner"].create({"name": "OverwriteTrue99xyz", "email": "old@test.com"})
49+
self._create_match_rule([{"field_id": self.name_field.id}], overwrite=True)
50+
result = self.env["res.partner"].load(
51+
["name", "email"],
52+
[["OverwriteTrue99xyz", "new@test.com"]],
53+
)
54+
self.assertFalse(result["messages"])
55+
partner.invalidate_recordset()
56+
self.assertEqual(partner.email, "new@test.com")
57+
58+
def test_load_match_overwrite_false(self):
59+
"""Match found + overwrite=False -> record skipped."""
60+
partner = self.env["res.partner"].create({"name": "OverwriteFalse99xyz", "email": "original@test.com"})
61+
self._create_match_rule([{"field_id": self.name_field.id}], overwrite=False)
62+
self.env["res.partner"].load(
63+
["name", "email"],
64+
[["OverwriteFalse99xyz", "changed@test.com"]],
65+
)
66+
partner.invalidate_recordset()
67+
self.assertEqual(partner.email, "original@test.com")
68+
69+
def test_load_no_match_creates_record(self):
70+
"""No match found -> new record created."""
71+
self._create_match_rule([{"field_id": self.name_field.id}])
72+
result = self.env["res.partner"].load(
73+
["name", "email"],
74+
[["BrandNewPartner99xyz", "brandnew@test.com"]],
75+
)
76+
self.assertFalse(result["messages"])
77+
partner = self.env["res.partner"].search([("name", "=", "BrandNewPartner99xyz")])
78+
self.assertEqual(len(partner), 1)
79+
self.assertEqual(partner.email, "brandnew@test.com")
80+
81+
def test_load_multiple_records_mixed(self):
82+
"""Mix of matched and new records in one import."""
83+
self.env["res.partner"].create({"name": "ExistingMixed99xyz", "email": "existing@test.com"})
84+
self._create_match_rule([{"field_id": self.name_field.id}], overwrite=True)
85+
result = self.env["res.partner"].load(
86+
["name", "email"],
87+
[
88+
["ExistingMixed99xyz", "updated@test.com"],
89+
["NewMixed99xyz", "newmixed@test.com"],
90+
],
91+
)
92+
self.assertFalse(result["messages"])
93+
existing = self.env["res.partner"].search([("name", "=", "ExistingMixed99xyz")])
94+
self.assertEqual(len(existing), 1)
95+
existing.invalidate_recordset()
96+
self.assertEqual(existing.email, "updated@test.com")
97+
new_partner = self.env["res.partner"].search([("name", "=", "NewMixed99xyz")])
98+
self.assertEqual(len(new_partner), 1)
99+
100+
def test_load_with_import_match_ids_context(self):
101+
"""Context import_match_ids filters to specific rules."""
102+
self._create_match_rule([{"field_id": self.name_field.id}])
103+
match2 = self._create_match_rule([{"field_id": self.email_field.id}])
104+
partner = self.env["res.partner"].create({"name": "CtxFilter99xyz", "email": "ctxfilter@test.com"})
105+
# Only use match2 (email rule); import with matching email but different name
106+
result = (
107+
self.env["res.partner"]
108+
.with_context(import_match_ids=[match2.id])
109+
.load(
110+
["name", "email"],
111+
[["CtxFilterRenamed99", "ctxfilter@test.com"]],
112+
)
113+
)
114+
self.assertFalse(result["messages"])
115+
partner.invalidate_recordset()
116+
self.assertEqual(partner.name, "CtxFilterRenamed99")
117+
118+
def test_load_appends_id_field(self):
119+
"""Auto-appends 'id' when not in fields list."""
120+
self._create_match_rule([{"field_id": self.name_field.id}])
121+
fields = ["name", "email"]
122+
self.env["res.partner"].load(
123+
fields,
124+
[["AppendIdTest99xyz", "appendid@test.com"]],
125+
)
126+
self.assertIn("id", fields)
127+
128+
def test_load_match_counts_tracking(self):
129+
"""_import_match_local.counts populated when import_match_ids in context."""
130+
self.env["res.partner"].create({"name": "CountsExisting99xyz", "email": "counts@test.com"})
131+
match = self._create_match_rule([{"field_id": self.name_field.id}], overwrite=True)
132+
_import_match_local.counts = None
133+
self.env["res.partner"].with_context(import_match_ids=[match.id]).load(
134+
["name", "email"],
135+
[
136+
["CountsExisting99xyz", "updated@test.com"],
137+
["CountsNew99xyz", "new@test.com"],
138+
],
139+
)
140+
counts = _import_match_local.counts
141+
self.assertIsNotNone(counts)
142+
self.assertEqual(counts["overwritten"], 1)
143+
self.assertEqual(counts["created"], 1)
144+
self.assertEqual(counts["skipped"], 0)

spp_import_match/tests/test_import_match_model.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,68 @@ def test_field_compute_name_with_sub_field(self):
162162
]
163163
)
164164
self.assertEqual(match.field_ids[0].name, "child_ids/name")
165+
166+
def test_onchange_field_id_skips_relational(self):
167+
"""_onchange_field_id skips duplicate check for relational fields."""
168+
parent_id_field = self.env["ir.model.fields"].search(
169+
[("name", "=", "parent_id"), ("model_id", "=", self.res_partner_model.id)],
170+
limit=1,
171+
)
172+
match = self._create_match_rule(
173+
[
174+
{"field_id": parent_id_field.id},
175+
{"field_id": parent_id_field.id},
176+
]
177+
)
178+
# Should NOT raise ValidationError because parent_id is many2one
179+
for field_rec in match.field_ids:
180+
field_rec._onchange_field_id()
181+
self.assertEqual(len(match.field_ids), 2)
182+
183+
def test_match_find_sub_field(self):
184+
"""_match_find handles sub_field matching for relational fields."""
185+
child = self.env["res.partner"].create({"name": "SubFieldChild_Uniq99xyz"})
186+
parent = self.env["res.partner"].create(
187+
{
188+
"name": "SubFieldParent_Uniq99xyz",
189+
"child_ids": [(4, child.id)],
190+
}
191+
)
192+
child_ids_field = self.env["ir.model.fields"].search(
193+
[
194+
("name", "=", "child_ids"),
195+
("model_id", "=", self.res_partner_model.id),
196+
],
197+
limit=1,
198+
)
199+
match = self._create_match_rule(
200+
[
201+
{
202+
"field_id": child_ids_field.id,
203+
"sub_field_id": self.name_field.id,
204+
}
205+
]
206+
)
207+
result = match._match_find(
208+
self.env["res.partner"],
209+
{"child_ids": [(0, 0, {"name": "SubFieldChild_Uniq99xyz"})]},
210+
{"child_ids/name": "SubFieldChild_Uniq99xyz", "id": None},
211+
)
212+
self.assertEqual(result, parent)
213+
214+
def test_match_find_multiple_combinations(self):
215+
"""_match_find iterates rules; first matching single result wins."""
216+
partner = self.env["res.partner"].create({"name": "MultiCombo99xyz", "email": "multicombo@test.com"})
217+
# First rule by email (lower sequence -> tried first)
218+
match1 = self._create_match_rule([{"field_id": self.email_field.id}])
219+
match1.sequence = 1
220+
# Second rule by name
221+
match2 = self._create_match_rule([{"field_id": self.name_field.id}])
222+
match2.sequence = 2
223+
# Email won't match, but name will
224+
result = self.env["spp.import.match"]._match_find(
225+
self.env["res.partner"],
226+
{"name": "MultiCombo99xyz", "email": "nomatch@test.com"},
227+
{"name": "MultiCombo99xyz", "email": "nomatch@test.com", "id": None},
228+
)
229+
self.assertEqual(result, partner)

0 commit comments

Comments
 (0)