Skip to content

Commit 061b10f

Browse files
committed
test(spp_registry): add test suite with 83% line+branch coverage
The module shipped with no tests; this adds 240 tests across 14 files covering constraints, computes, onchanges, the CRUD-override metric invalidation chain, the disable-registrant wizard, the mail controller overrides, the SQL aggregation builder, and the unlink permission guard. Tests surfaced and pinned several real issues for follow-up: - mail.py::mail_message_update_content calls ir.attachment._check_attachments_access, which no longer exists in Odoo 19; the endpoint is broken. - _query_members_aggregate uses the deprecated expression.expression() API, which silently produces empty WHERE clauses on Odoo 19. As a result the disabled/ended-membership filters never apply and count_individuals() over-counts. Affects all callers that build group metrics off the count. - _onchange_source_destination_clear_relation clears a relation type even when it's valid for the new partner-type pair (e.g. 'head' is valid for both i2i and mixed). - _compute_phone_sanitized stores the unsanitized fallback value when parsing fails, so phone_sanitized can hold non-E164 strings. - _check_age_is_integer is dead code (constrains on a non-stored compute). - _check_date_collected is onchange-only and bypassed by programmatic create. - disable_registrant wizard has no idempotency guard, unlike its siblings. - count_individuals(relationship_kinds=...) expects display strings, not vocab code slugs — silent footgun. Each finding has a skipped test with a detailed TODO documenting the discovery and the remediation path, so the value isn't lost.
1 parent dcafa51 commit 061b10f

15 files changed

Lines changed: 3497 additions & 1 deletion

spp_registry/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"name": "OpenSPP Registry",
55
"category": "OpenSPP/Core",
6-
"version": "19.0.2.1.1",
6+
"version": "19.0.2.1.2",
77
"sequence": 1,
88
"author": "OpenSPP.org",
99
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_registry/tests/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
from . import common
3+
from . import test_constraints
4+
from . import test_unlink_permissions
5+
from . import test_mail_controllers
6+
from . import test_relationships
7+
from . import test_metric_invalidation
8+
from . import test_wizard_disable_registrant
9+
from . import test_individual_name
10+
from . import test_phone_number
11+
from . import test_reg_id
12+
from . import test_membership_constraints
13+
from . import test_registrant_misc
14+
from . import test_group_aggregation

spp_registry/tests/common.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Shared fixtures for spp_registry tests."""
3+
4+
from odoo.tests import TransactionCase
5+
6+
7+
class RegistryCommon(TransactionCase):
8+
"""Base class with the registrants, groups and vocab codes used across
9+
the security-priority test suites.
10+
11+
The head vocabulary code is loaded by spp_vocabulary at module install
12+
time (see vocabulary_group_membership_type.xml); we look it up rather
13+
than recreate it so the test exercises the same row production uses.
14+
"""
15+
16+
@classmethod
17+
def setUpClass(cls):
18+
super().setUpClass()
19+
cls.Partner = cls.env["res.partner"]
20+
cls.Membership = cls.env["spp.group.membership"]
21+
cls.VocabCode = cls.env["spp.vocabulary.code"]
22+
23+
cls.head_code = cls.VocabCode.sudo().get_code(
24+
"urn:openspp:vocab:group-membership-type", "head"
25+
)
26+
27+
cls.group = cls.Partner.create(
28+
{"name": "Test Household", "is_registrant": True, "is_group": True}
29+
)
30+
cls.individual_a = cls.Partner.create(
31+
{"name": "Alice", "is_registrant": True, "is_group": False}
32+
)
33+
cls.individual_b = cls.Partner.create(
34+
{"name": "Bob", "is_registrant": True, "is_group": False}
35+
)
36+
37+
@classmethod
38+
def _make_user(cls, login, group_xmlids):
39+
"""Create an internal user with the given res.groups xmlids."""
40+
groups = cls.env["res.groups"]
41+
for xmlid in group_xmlids:
42+
groups |= cls.env.ref(xmlid)
43+
return cls.env["res.users"].create(
44+
{
45+
"name": login,
46+
"login": login,
47+
"email": f"{login}@example.test",
48+
"group_ids": [(6, 0, groups.ids)],
49+
}
50+
)
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Data-integrity constraints on registry models.
3+
4+
Covers:
5+
- res.partner (group) ``_validate_unique_membership_types`` — only one
6+
``head`` membership per group, exercised on both ``create`` and ``write``.
7+
- res.partner (registrant) ``_check_registration_date`` — registration
8+
date cannot be in the future, and cannot precede the birthdate.
9+
- spp.id.type ``_check_namespace_uri_format`` (ADR-007) plus the
10+
lowercase-normalisation behaviour of ``create`` / ``write``.
11+
"""
12+
13+
from datetime import date, timedelta
14+
15+
from odoo.exceptions import ValidationError
16+
from odoo.tests import tagged
17+
18+
from .common import RegistryCommon
19+
20+
21+
@tagged("post_install", "-at_install")
22+
class TestUniqueMembershipTypes(RegistryCommon):
23+
"""spp_registry/models/group.py::_validate_unique_membership_types"""
24+
25+
def test_single_head_membership_allowed(self):
26+
"""A group with exactly one head member writes without error."""
27+
self.Membership.create(
28+
{
29+
"group": self.group.id,
30+
"individual": self.individual_a.id,
31+
"membership_type_ids": [(6, 0, [self.head_code.id])],
32+
}
33+
)
34+
# write() override runs validation again — should be a no-op.
35+
self.group.write({"name": "Renamed Household"})
36+
37+
def test_two_heads_rejected_on_create(self):
38+
"""Creating a group with two heads in one transaction is rejected."""
39+
with self.assertRaises(ValidationError):
40+
self.Partner.create(
41+
{
42+
"name": "Twin-headed Household",
43+
"is_registrant": True,
44+
"is_group": True,
45+
"group_membership_ids": [
46+
(
47+
0,
48+
0,
49+
{
50+
"individual": self.individual_a.id,
51+
"membership_type_ids": [(6, 0, [self.head_code.id])],
52+
},
53+
),
54+
(
55+
0,
56+
0,
57+
{
58+
"individual": self.individual_b.id,
59+
"membership_type_ids": [(6, 0, [self.head_code.id])],
60+
},
61+
),
62+
],
63+
}
64+
)
65+
66+
def test_two_heads_rejected_on_group_write(self):
67+
"""The group-side validator only fires from ``group.write/create``,
68+
not from membership-side writes. Trip it by editing the group's
69+
``group_membership_ids`` o2m through ``res.partner.write``.
70+
71+
The form-side membership onchange catches the membership-write
72+
path separately — covered in ``test_membership_constraints.py``.
73+
"""
74+
self.Membership.create(
75+
{
76+
"group": self.group.id,
77+
"individual": self.individual_a.id,
78+
"membership_type_ids": [(6, 0, [self.head_code.id])],
79+
}
80+
)
81+
second = self.Membership.create(
82+
{
83+
"group": self.group.id,
84+
"individual": self.individual_b.id,
85+
}
86+
)
87+
with self.assertRaises(ValidationError):
88+
# Rewriting the membership through the group's o2m triggers
89+
# group.write() and re-runs ``_validate_unique_membership_types``.
90+
self.group.write(
91+
{
92+
"group_membership_ids": [
93+
(1, second.id, {"membership_type_ids": [(6, 0, [self.head_code.id])]}),
94+
],
95+
}
96+
)
97+
98+
def test_no_head_code_in_vocabulary_short_circuits(self):
99+
"""When the 'head' code is missing the validator must be a no-op.
100+
101+
Some deployments customise the vocabulary; the validator's first
102+
branch (``if not head_code: return``) guards against false positives.
103+
"""
104+
# TODO: simulate a missing head code by archiving / replacing the
105+
# vocabulary code in a savepoint, then assert ``create`` succeeds
106+
# even with multiple memberships that *would* have been heads.
107+
self.skipTest("not yet implemented — see TODO")
108+
109+
110+
@tagged("post_install", "-at_install")
111+
class TestRegistrationDateConstraint(RegistryCommon):
112+
"""spp_registry/models/registrant.py::_check_registration_date"""
113+
114+
def test_future_registration_date_rejected(self):
115+
"""Registration date in the future raises ValidationError."""
116+
future = date.today() + timedelta(days=1)
117+
with self.assertRaises(ValidationError):
118+
self.individual_a.write({"registration_date": future})
119+
120+
def test_today_is_allowed(self):
121+
"""Registration date == today is the boundary that must pass."""
122+
self.individual_a.write({"registration_date": date.today()})
123+
self.assertEqual(self.individual_a.registration_date, date.today())
124+
125+
def test_registration_before_birthdate_rejected(self):
126+
"""Registration date < birthdate raises ValidationError.
127+
128+
Requires the ``birthdate`` field added by individual.py; if running
129+
with a pruned dependency tree the constraint's defensive
130+
``"birthdate" in record`` short-circuits, which is itself part of
131+
the contract.
132+
"""
133+
if "birthdate" not in self.individual_a:
134+
self.skipTest("birthdate field not present in this build")
135+
self.individual_a.write({"birthdate": date(1990, 1, 1)})
136+
with self.assertRaises(ValidationError):
137+
self.individual_a.write({"registration_date": date(1989, 12, 31)})
138+
139+
def test_registration_equal_to_birthdate_allowed(self):
140+
"""Same-day birth + registration is the boundary that must pass."""
141+
if "birthdate" not in self.individual_a:
142+
self.skipTest("birthdate field not present in this build")
143+
self.individual_a.write(
144+
{
145+
"birthdate": date(1990, 1, 1),
146+
"registration_date": date(1990, 1, 1),
147+
}
148+
)
149+
150+
151+
@tagged("post_install", "-at_install")
152+
class TestIDTypeNamespaceURI(RegistryCommon):
153+
"""spp_registry/models/reg_id.py::SPPIDType — ADR-007 URI handling."""
154+
155+
@classmethod
156+
def setUpClass(cls):
157+
super().setUpClass()
158+
cls.IDType = cls.env["spp.id.type"]
159+
160+
def test_valid_uri_accepted(self):
161+
rec = self.IDType.create(
162+
{"name": "PSA National ID", "namespace_uri": "urn:gov:ph:psa:national-id"}
163+
)
164+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:psa:national-id")
165+
166+
def test_create_lowercases_namespace(self):
167+
"""create() override should normalise to lowercase + trim whitespace."""
168+
rec = self.IDType.create(
169+
{"name": "Mixed Case Type", "namespace_uri": " URN:GOV:PH:Mixed "}
170+
)
171+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:mixed")
172+
173+
def test_write_lowercases_namespace(self):
174+
"""write() override should normalise to lowercase + trim whitespace."""
175+
rec = self.IDType.create(
176+
{"name": "Writable Type", "namespace_uri": "urn:gov:ph:initial"}
177+
)
178+
rec.write({"namespace_uri": " URN:GOV:PH:UPDATED "})
179+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:updated")
180+
181+
def test_invalid_scheme_rejected(self):
182+
"""Non-``urn:`` URIs violate the ADR-007 pattern."""
183+
with self.assertRaises(ValidationError):
184+
self.IDType.create(
185+
{"name": "Bad Scheme", "namespace_uri": "http://example.org/id"}
186+
)
187+
188+
def test_missing_type_component_rejected(self):
189+
"""``urn:<namespace>`` without the trailing ``:<type>`` is rejected."""
190+
with self.assertRaises(ValidationError):
191+
self.IDType.create({"name": "Truncated", "namespace_uri": "urn:gov"})
192+
193+
def test_disallowed_characters_rejected(self):
194+
"""The pattern only permits [a-z0-9._-] in each segment."""
195+
with self.assertRaises(ValidationError):
196+
self.IDType.create(
197+
{"name": "Spaces", "namespace_uri": "urn:gov:ph:national id"}
198+
)
199+
200+
def test_empty_namespace_is_allowed(self):
201+
"""Empty / falsy namespace_uri short-circuits validation (see code)."""
202+
rec = self.IDType.create({"name": "No Namespace", "namespace_uri": False})
203+
self.assertFalse(rec.namespace_uri)
204+
205+
def test_duplicate_namespace_uri_rejected(self):
206+
"""The ``_unique_namespace_uri`` SQL constraint must fire."""
207+
self.IDType.create(
208+
{"name": "First", "namespace_uri": "urn:gov:ph:psa:national-id"}
209+
)
210+
# TODO: assert IntegrityError via with self.assertRaises + flush().
211+
# SQL constraints raise on flush, not on the ORM call, so the
212+
# idiomatic Odoo pattern is ``with mute_logger(...): self.env.flush_all()``.
213+
self.skipTest("not yet implemented — see TODO")
214+
215+
def test_duplicate_name_rejected(self):
216+
"""spp.id.type._check_name forbids duplicate names (case-sensitive)."""
217+
self.IDType.create({"name": "National ID"})
218+
with self.assertRaises(ValidationError):
219+
self.IDType.create({"name": "National ID"})
220+
221+
def test_empty_name_rejected(self):
222+
with self.assertRaises(ValidationError):
223+
self.IDType.create({"name": False})

0 commit comments

Comments
 (0)