Skip to content

Commit 5900a2e

Browse files
authored
Merge pull request #214 from OpenSPP/test/spp-registry-coverage
test(spp_registry): add test suite with 83% line+branch coverage
2 parents dcafa51 + 28b2a0d commit 5900a2e

15 files changed

Lines changed: 3341 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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("urn:openspp:vocab:group-membership-type", "head")
24+
25+
cls.group = cls.Partner.create({"name": "Test Household", "is_registrant": True, "is_group": True})
26+
cls.individual_a = cls.Partner.create({"name": "Alice", "is_registrant": True, "is_group": False})
27+
cls.individual_b = cls.Partner.create({"name": "Bob", "is_registrant": True, "is_group": False})
28+
29+
@classmethod
30+
def _make_user(cls, login, group_xmlids):
31+
"""Create an internal user with the given res.groups xmlids."""
32+
groups = cls.env["res.groups"]
33+
for xmlid in group_xmlids:
34+
groups |= cls.env.ref(xmlid)
35+
return cls.env["res.users"].create(
36+
{
37+
"name": login,
38+
"login": login,
39+
"email": f"{login}@example.test",
40+
"group_ids": [(6, 0, groups.ids)],
41+
}
42+
)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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({"name": "PSA National ID", "namespace_uri": "urn:gov:ph:psa:national-id"})
162+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:psa:national-id")
163+
164+
def test_create_lowercases_namespace(self):
165+
"""create() override should normalise to lowercase + trim whitespace."""
166+
rec = self.IDType.create({"name": "Mixed Case Type", "namespace_uri": " URN:GOV:PH:Mixed "})
167+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:mixed")
168+
169+
def test_write_lowercases_namespace(self):
170+
"""write() override should normalise to lowercase + trim whitespace."""
171+
rec = self.IDType.create({"name": "Writable Type", "namespace_uri": "urn:gov:ph:initial"})
172+
rec.write({"namespace_uri": " URN:GOV:PH:UPDATED "})
173+
self.assertEqual(rec.namespace_uri, "urn:gov:ph:updated")
174+
175+
def test_invalid_scheme_rejected(self):
176+
"""Non-``urn:`` URIs violate the ADR-007 pattern."""
177+
with self.assertRaises(ValidationError):
178+
self.IDType.create({"name": "Bad Scheme", "namespace_uri": "http://example.org/id"})
179+
180+
def test_missing_type_component_rejected(self):
181+
"""``urn:<namespace>`` without the trailing ``:<type>`` is rejected."""
182+
with self.assertRaises(ValidationError):
183+
self.IDType.create({"name": "Truncated", "namespace_uri": "urn:gov"})
184+
185+
def test_disallowed_characters_rejected(self):
186+
"""The pattern only permits [a-z0-9._-] in each segment."""
187+
with self.assertRaises(ValidationError):
188+
self.IDType.create({"name": "Spaces", "namespace_uri": "urn:gov:ph:national id"})
189+
190+
def test_empty_namespace_is_allowed(self):
191+
"""Empty / falsy namespace_uri short-circuits validation (see code)."""
192+
rec = self.IDType.create({"name": "No Namespace", "namespace_uri": False})
193+
self.assertFalse(rec.namespace_uri)
194+
195+
def test_duplicate_namespace_uri_rejected(self):
196+
"""The ``_unique_namespace_uri`` SQL constraint must fire."""
197+
self.IDType.create({"name": "First", "namespace_uri": "urn:gov:ph:psa:national-id"})
198+
# TODO: assert IntegrityError via with self.assertRaises + flush().
199+
# SQL constraints raise on flush, not on the ORM call, so the
200+
# idiomatic Odoo pattern is ``with mute_logger(...): self.env.flush_all()``.
201+
self.skipTest("not yet implemented — see TODO")
202+
203+
def test_duplicate_name_rejected(self):
204+
"""spp.id.type._check_name forbids duplicate names (case-sensitive)."""
205+
self.IDType.create({"name": "National ID"})
206+
with self.assertRaises(ValidationError):
207+
self.IDType.create({"name": "National ID"})
208+
209+
def test_empty_name_rejected(self):
210+
with self.assertRaises(ValidationError):
211+
self.IDType.create({"name": False})

0 commit comments

Comments
 (0)