Skip to content

Commit dc66d1d

Browse files
test(spp_audit): add decorator tests, fix Markup sanitization in write/unlink
Add 11 tests covering all three audit decorator methods (create, write, unlink) including multi-record operations, audit log verification, and recursive audit prevention. Fix Markup sanitization in audit_write and audit_unlink to iterate all records instead of only the first — the same bug pattern fixed in audit_create by the previous commit.
1 parent 0530092 commit dc66d1d

5 files changed

Lines changed: 280 additions & 5 deletions

File tree

spp_audit/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ Changelog
150150

151151
- fix: use @api.model_create_multi for audit_create to support Odoo 19
152152
create overrides (#138)
153+
- fix: Markup sanitization in audit_write and audit_unlink now handles
154+
all records, not just the first
153155

154156
19.0.2.0.0
155157
~~~~~~~~~~

spp_audit/readme/HISTORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
### 19.0.2.0.1
22

33
- fix: use @api.model_create_multi for audit_create to support Odoo 19 create overrides (#138)
4+
- fix: Markup sanitization in audit_write and audit_unlink now handles all records, not just the first
45

56
### 19.0.2.0.0
67

spp_audit/static/description/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ <h1>19.0.2.0.1</h1>
520520
<ul class="simple">
521521
<li>fix: use &#64;api.model_create_multi for audit_create to support Odoo 19
522522
create overrides (#138)</li>
523+
<li>fix: Markup sanitization in audit_write and audit_unlink now handles
524+
all records, not just the first</li>
523525
</ul>
524526
</div>
525527
<div class="section" id="section-2">

spp_audit/tests/test_spp_audit_rule.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33

44
class AuditRuleTest(TransactionCase):
5+
"""Tests for spp.audit.rule core functionality."""
6+
57
@classmethod
68
def setUpClass(cls):
79
super().setUpClass()
@@ -88,3 +90,268 @@ def test_get_audit_log_vals(self):
8890
self.assertEqual(res_id, vals["res_id"])
8991
self.assertEqual(method, vals["method"])
9092
self.assertEqual(repr(data[res_id]), vals["data"])
93+
94+
95+
class TestAuditCreateMulti(TransactionCase):
96+
"""Regression tests for audit_create with @api.model_create_multi.
97+
98+
The audit decorator monkey-patches create() on audited models. It must
99+
use @api.model_create_multi so the vals_list flows correctly through
100+
the origin chain to downstream create overrides.
101+
"""
102+
103+
@classmethod
104+
def setUpClass(cls):
105+
super().setUpClass()
106+
cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1)
107+
# Ensure an audit rule with create logging exists for res.partner
108+
cls.audit_rule = cls.env["spp.audit.rule"].search([("model_id", "=", cls.partner_model.id)], limit=1)
109+
if not cls.audit_rule:
110+
cls.audit_rule = cls.env["spp.audit.rule"].create(
111+
{
112+
"name": "Test Partner Audit",
113+
"model_id": cls.partner_model.id,
114+
"is_log_create": True,
115+
"is_log_write": False,
116+
"is_log_unlink": False,
117+
}
118+
)
119+
else:
120+
cls.audit_rule.write({"is_log_create": True})
121+
122+
def test_multi_create_returns_all_records(self):
123+
"""Creating multiple records in one call must return all of them."""
124+
partners = self.env["res.partner"].create(
125+
[
126+
{"name": "Audit Multi A"},
127+
{"name": "Audit Multi B"},
128+
{"name": "Audit Multi C"},
129+
]
130+
)
131+
self.assertEqual(len(partners), 3)
132+
self.assertEqual(partners[0].name, "Audit Multi A")
133+
self.assertEqual(partners[1].name, "Audit Multi B")
134+
self.assertEqual(partners[2].name, "Audit Multi C")
135+
136+
def test_multi_create_produces_audit_logs(self):
137+
"""Each record from a multi-create should have an audit log entry."""
138+
partners = self.env["res.partner"].create(
139+
[
140+
{"name": "Audit Log A"},
141+
{"name": "Audit Log B"},
142+
]
143+
)
144+
logs = self.env["spp.audit.log"].search(
145+
[
146+
("model_id", "=", self.partner_model.id),
147+
("method", "=", "create"),
148+
("res_id", "in", partners.ids),
149+
]
150+
)
151+
logged_ids = set(logs.mapped("res_id"))
152+
for partner in partners:
153+
self.assertIn(
154+
partner.id,
155+
logged_ids,
156+
f"Audit log missing for partner {partner.name} (id={partner.id})",
157+
)
158+
159+
def test_single_create_still_works(self):
160+
"""Single-dict create must still work with the model_create_multi decorator."""
161+
partner = self.env["res.partner"].create({"name": "Audit Single"})
162+
self.assertTrue(partner.exists())
163+
self.assertEqual(partner.name, "Audit Single")
164+
165+
166+
class TestAuditWrite(TransactionCase):
167+
"""Tests for the audit_write decorator method."""
168+
169+
@classmethod
170+
def setUpClass(cls):
171+
super().setUpClass()
172+
cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1)
173+
cls.audit_rule = cls.env["spp.audit.rule"].search([("model_id", "=", cls.partner_model.id)], limit=1)
174+
if not cls.audit_rule:
175+
cls.audit_rule = cls.env["spp.audit.rule"].create(
176+
{
177+
"name": "Test Partner Audit Write",
178+
"model_id": cls.partner_model.id,
179+
"is_log_create": False,
180+
"is_log_write": True,
181+
"is_log_unlink": False,
182+
}
183+
)
184+
else:
185+
cls.audit_rule.write({"is_log_write": True})
186+
187+
def test_write_produces_audit_log(self):
188+
"""Writing to an audited record should produce an audit log entry."""
189+
partner = self.env["res.partner"].create({"name": "Write Test"})
190+
partner.write({"name": "Write Test Updated"})
191+
192+
logs = self.env["spp.audit.log"].search(
193+
[
194+
("model_id", "=", self.partner_model.id),
195+
("method", "=", "write"),
196+
("res_id", "=", partner.id),
197+
]
198+
)
199+
self.assertTrue(logs, "Expected an audit log for the write operation")
200+
201+
def test_write_captures_old_and_new_values(self):
202+
"""Audit log data should contain both old and new values."""
203+
partner = self.env["res.partner"].create({"name": "Old Name"})
204+
partner.write({"name": "New Name"})
205+
206+
log = self.env["spp.audit.log"].search(
207+
[
208+
("model_id", "=", self.partner_model.id),
209+
("method", "=", "write"),
210+
("res_id", "=", partner.id),
211+
],
212+
limit=1,
213+
order="id desc",
214+
)
215+
self.assertTrue(log)
216+
# The data field stores repr() of the diff dict which includes old/new
217+
self.assertIn("old", log.data)
218+
self.assertIn("new", log.data)
219+
220+
def test_write_multiple_records(self):
221+
"""Writing to multiple records at once should log each one."""
222+
partners = self.env["res.partner"].create(
223+
[
224+
{"name": "Batch Write A"},
225+
{"name": "Batch Write B"},
226+
]
227+
)
228+
partners.write({"phone": "+1234567890"})
229+
230+
logs = self.env["spp.audit.log"].search(
231+
[
232+
("model_id", "=", self.partner_model.id),
233+
("method", "=", "write"),
234+
("res_id", "in", partners.ids),
235+
]
236+
)
237+
logged_ids = set(logs.mapped("res_id"))
238+
for partner in partners:
239+
self.assertIn(partner.id, logged_ids)
240+
241+
def test_write_no_recursive_audit(self):
242+
"""Write with audit_in_progress context should not create duplicate logs."""
243+
partner = self.env["res.partner"].create({"name": "Recurse Test"})
244+
245+
# Count logs before
246+
log_count_before = self.env["spp.audit.log"].search_count(
247+
[
248+
("model_id", "=", self.partner_model.id),
249+
("method", "=", "write"),
250+
("res_id", "=", partner.id),
251+
]
252+
)
253+
254+
# Write with audit_in_progress flag — should skip audit logging
255+
partner.with_context(audit_in_progress=True).write({"name": "Skipped"})
256+
257+
log_count_after = self.env["spp.audit.log"].search_count(
258+
[
259+
("model_id", "=", self.partner_model.id),
260+
("method", "=", "write"),
261+
("res_id", "=", partner.id),
262+
]
263+
)
264+
self.assertEqual(
265+
log_count_before,
266+
log_count_after,
267+
"audit_in_progress context should prevent audit logging",
268+
)
269+
270+
271+
class TestAuditUnlink(TransactionCase):
272+
"""Tests for the audit_unlink decorator method."""
273+
274+
@classmethod
275+
def setUpClass(cls):
276+
super().setUpClass()
277+
cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1)
278+
cls.audit_rule = cls.env["spp.audit.rule"].search([("model_id", "=", cls.partner_model.id)], limit=1)
279+
if not cls.audit_rule:
280+
cls.audit_rule = cls.env["spp.audit.rule"].create(
281+
{
282+
"name": "Test Partner Audit Unlink",
283+
"model_id": cls.partner_model.id,
284+
"is_log_create": False,
285+
"is_log_write": False,
286+
"is_log_unlink": True,
287+
}
288+
)
289+
else:
290+
cls.audit_rule.write({"is_log_unlink": True})
291+
292+
def test_unlink_produces_audit_log(self):
293+
"""Deleting an audited record should produce an audit log entry."""
294+
partner = self.env["res.partner"].create({"name": "Delete Me"})
295+
partner_id = partner.id
296+
partner.unlink()
297+
298+
logs = self.env["spp.audit.log"].search(
299+
[
300+
("model_id", "=", self.partner_model.id),
301+
("method", "=", "unlink"),
302+
("res_id", "=", partner_id),
303+
]
304+
)
305+
self.assertTrue(logs, "Expected an audit log for the unlink operation")
306+
307+
def test_unlink_logs_old_values(self):
308+
"""Audit log for unlink should contain the old values of the deleted record."""
309+
partner = self.env["res.partner"].create({"name": "Unlink Values Test"})
310+
partner_id = partner.id
311+
partner.unlink()
312+
313+
log = self.env["spp.audit.log"].search(
314+
[
315+
("model_id", "=", self.partner_model.id),
316+
("method", "=", "unlink"),
317+
("res_id", "=", partner_id),
318+
],
319+
limit=1,
320+
order="id desc",
321+
)
322+
self.assertTrue(log)
323+
# Unlink logs old values (the state before deletion)
324+
self.assertIn("old", log.data)
325+
326+
def test_unlink_multiple_records(self):
327+
"""Deleting multiple records at once should log each one."""
328+
partners = self.env["res.partner"].create(
329+
[
330+
{"name": "Batch Delete A"},
331+
{"name": "Batch Delete B"},
332+
]
333+
)
334+
partner_ids = partners.ids
335+
partners.unlink()
336+
337+
logs = self.env["spp.audit.log"].search(
338+
[
339+
("model_id", "=", self.partner_model.id),
340+
("method", "=", "unlink"),
341+
("res_id", "in", partner_ids),
342+
]
343+
)
344+
logged_ids = set(logs.mapped("res_id"))
345+
for pid in partner_ids:
346+
self.assertIn(pid, logged_ids)
347+
348+
def test_unlink_record_is_actually_deleted(self):
349+
"""The record should actually be deleted after audit logging."""
350+
partner = self.env["res.partner"].create({"name": "Really Delete"})
351+
partner_id = partner.id
352+
partner.unlink()
353+
354+
self.assertFalse(
355+
self.env["res.partner"].search([("id", "=", partner_id)]),
356+
"Record should be deleted after audit_unlink",
357+
)

spp_audit/tools/decorator.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@ def audit_write(self, vals):
7373
if new_values and old_values_copy:
7474
keys = new_values[0].keys()
7575
for key in keys:
76-
if str(type(new_values[0][key])) == "<class 'markupsafe.Markup'>":
77-
new_values[0][key] = str(new_values[0][key])
78-
old_values_copy[0][key] = str(old_values_copy[0][key])
76+
for nv, ov in zip(new_values, old_values_copy, strict=False):
77+
if str(type(nv[key])) == "<class 'markupsafe.Markup'>":
78+
nv[key] = str(nv[key])
79+
if str(type(ov[key])) == "<class 'markupsafe.Markup'>":
80+
ov[key] = str(ov[key])
7981

8082
rules.log("write", old_values_copy, new_values)
8183
return result
@@ -92,8 +94,9 @@ def audit_unlink(self):
9294
if old_values:
9395
keys = old_values[0].keys()
9496
for key in keys:
95-
if str(type(old_values[0][key])) == "<class 'markupsafe.Markup'>":
96-
old_values[0][key] = str(old_values[0][key])
97+
for ov in old_values:
98+
if str(type(ov[key])) == "<class 'markupsafe.Markup'>":
99+
ov[key] = str(ov[key])
97100

98101
rules.log("unlink", old_values)
99102
return audit_unlink.origin(self)

0 commit comments

Comments
 (0)