|
| 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