|
| 1 | +# Design: JSON Patch side-effect regression tests (PR #24489 / DHIS2-21852) |
| 2 | + |
| 3 | +Date: 2026-07-23 |
| 4 | +PR: https://github.com/dhis2/dhis2-core/pull/24489 |
| 5 | +Branch: `fix-userrole-jsonpatch-lazy-members` |
| 6 | +Author: Morten (netroms) |
| 7 | + |
| 8 | +## Problem |
| 9 | + |
| 10 | +PR #24489 changes `JsonPatchManager` system-wide: during `apply`, non-owner collection properties that are not referenced by any patch path are omitted from serialization so lazy Hibernate collections (e.g. `UserRole.members`) are not initialized on scalar PATCH. |
| 11 | + |
| 12 | +Review feedback: |
| 13 | + |
| 14 | +1. **Jan (retracted in weekly meeting)** — filter mechanics complexity. Out of scope for this follow-up. |
| 15 | +2. **David** — worried about unknown side effects because the change applies to all PATCHes, not only `/userRoles`. Asked whether PUT has the same issue. |
| 16 | + |
| 17 | +Existing PR tests are almost entirely UserRole-focused. That is not enough to affirm the generic rule. |
| 18 | + |
| 19 | +## PUT conclusion (no code change) |
| 20 | + |
| 21 | +PUT does **not** share this bug path. |
| 22 | + |
| 23 | +| | PATCH `/{uid}` | PUT `/{uid}` | |
| 24 | +|---|---|---| |
| 25 | +| Entry | `AbstractCrudController.patchObject` → `doPatch` | `AbstractCrudController.putJsonObject` | |
| 26 | +| Core | `jsonPatchManager.apply(patch, persistedObject)` | `deserializeJsonEntity(request)` only | |
| 27 | +| Then | `importMetadata(UPDATE)` | `importMetadata(UPDATE)` | |
| 28 | + |
| 29 | +Evidence: `AbstractCrudController` PUT ~393–422 never calls `JsonPatchManager`. `UserRoleController` does not override PUT/PATCH. |
| 30 | + |
| 31 | +PUT can still be heavy if the client sends a large `users` array; that is payload size, not live lazy-collection hydration via `valueToTree` / `handleCollectionUpdates`. |
| 32 | + |
| 33 | +No PUT regression tests are required for merge. Document the distinction in the PR reply. |
| 34 | + |
| 35 | +## Fix rule under test (unchanged production behavior) |
| 36 | + |
| 37 | +Skip a collection property only when all hold: |
| 38 | + |
| 39 | +1. `property.isCollection()` |
| 40 | +2. `!property.isOwner()` |
| 41 | +3. patch paths contain neither `property.getName()` nor `property.getCollectionName()` |
| 42 | + |
| 43 | +Owner collections must always serialize: metadata import UPDATE would clear them if omitted. |
| 44 | + |
| 45 | +Note: `UserGroup.users` (`members`) is **owner** (hbm has no `inverse`). The PR description implying UserGroup members benefit from the skip is slightly wrong; owner collections are still fully walked. Tests must document that contrast. |
| 46 | + |
| 47 | +## Goal |
| 48 | + |
| 49 | +Add integration + focused web-api tests that lock the skip rule on multiple entity types so David's system-wide side-effect concern is answered with evidence. |
| 50 | + |
| 51 | +## Non-goals |
| 52 | + |
| 53 | +- Production code changes (unless a test exposes a real bug) |
| 54 | +- PUT tests as merge gates |
| 55 | +- Gatling / perf expansion |
| 56 | +- Reworking Jan's filter-mechanics feedback |
| 57 | +- Schema-wide brute-force PATCH of every type |
| 58 | + |
| 59 | +## Invariants |
| 60 | + |
| 61 | +| ID | Invariant | Layer | |
| 62 | +|---|---|---| |
| 63 | +| I1 | Unreferenced **non-owner** collection stays lazy (`!Hibernate.isInitialized`) after scalar `jsonPatchManager.apply` | integration | |
| 64 | +| I2 | **Owner** collections still present on the patched object after scalar apply (not wiped by omit) | integration | |
| 65 | +| I3 | Explicit patch path targeting a non-owner collection still applies without throw (path-aware skip, not a ban) | integration (existing UserRole `/users`) | |
| 66 | +| I4 | HTTP PATCH of a scalar leaves unreferenced membership/children/groups unchanged on GET | web-api | |
| 67 | +| I5 | HTTP PATCH `replace /users` on UserRole does not change DB membership (inverse + no setter) | web-api | |
| 68 | + |
| 69 | +## Representative types |
| 70 | + |
| 71 | +| Entity | Collection | Owner? | Role in matrix | |
| 72 | +|---|---|---|---| |
| 73 | +| UserRole | `users` / field `members` | no | primary bug; already covered | |
| 74 | +| UserRole | `authorities` | yes | owner preserve; already covered | |
| 75 | +| OrganisationUnit | `children`, `users` | no | second fat inverse type | |
| 76 | +| User | `userGroups` (field `groups`) | no | common PATCH target; inverse | |
| 77 | +| User | `userRoles` | yes | owner must remain on patched user | |
| 78 | +| UserGroup | `users` (field `members`) | yes | must **not** be skipped; round-trip | |
| 79 | +| UserGroup | `managedByGroups` | no | skip when unreferenced | |
| 80 | +| DataElementGroup | `dataElements` / members | yes | owner wipe guard outside user domain | |
| 81 | + |
| 82 | +## Test plan |
| 83 | + |
| 84 | +### A. `JsonPatchManagerTest` (integration) |
| 85 | + |
| 86 | +File: `dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/jsonpatch/JsonPatchManagerTest.java` |
| 87 | + |
| 88 | +Keep existing PR tests: |
| 89 | + |
| 90 | +- `testUserRoleScalarPatchDoesNotInitializeMembers` (I1) |
| 91 | +- `testUserRoleOwnerAuthoritiesPreservedOnNamePatch` (I2) |
| 92 | +- `testUserRoleUsersPathPatchDoesNotThrow` (I3) |
| 93 | + |
| 94 | +Add: |
| 95 | + |
| 96 | +1. **`testOrganisationUnitScalarPatchDoesNotInitializeInverseCollections`** |
| 97 | + - Setup: parent OU, 2 children, 1 user assigned to parent; `clearSession`; reload parent |
| 98 | + - Assert children/users lazy before apply |
| 99 | + - Patch: `replace /name` |
| 100 | + - Assert name updated; `!Hibernate.isInitialized(children)`; `!Hibernate.isInitialized(users)` |
| 101 | + |
| 102 | +2. **`testUserScalarPatchDoesNotInitializeUserGroups`** |
| 103 | + - Setup: user with a userRole and membership in a userGroup; `clearSession`; reload user |
| 104 | + - Assert `groups` lazy before apply |
| 105 | + - Patch: `replace /firstName` |
| 106 | + - Assert firstName updated; `!isInitialized(groups)`; patched object still has non-empty `userRoles` (I2 owner) |
| 107 | + |
| 108 | +3. **`testUserGroupScalarPatchKeepsOwnerUsersAndSkipsManagedBy`** |
| 109 | + - Setup: group with 2 users; optional managedByGroups link; `clearSession`; reload |
| 110 | + - Patch: `replace /name` |
| 111 | + - Assert patched name; patched `users`/`members` size == 2 (owner not omitted) |
| 112 | + - Assert `!isInitialized(managedByGroups)` when that collection was left lazy |
| 113 | + |
| 114 | +4. **`testDataElementGroupNamePatchPreservesOwnerMembers`** |
| 115 | + - Setup: DEG with 2 data elements (same spirit as existing collection test) |
| 116 | + - Patch: `replace /name` |
| 117 | + - Assert patched name; member count still 2 |
| 118 | + |
| 119 | +Setup pattern: save graph → `clearSession()` → reload → assert lazy where relevant → `jsonPatchManager.apply` → assert. Match style of existing UserRole lazy test (DisplayName, Hibernate.isInitialized). |
| 120 | + |
| 121 | +### B. Web-api: new `JsonPatchSideEffectControllerTest` |
| 122 | + |
| 123 | +File: `dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/JsonPatchSideEffectControllerTest.java` |
| 124 | + |
| 125 | +Base: `H2ControllerIntegrationTestBase`, `@Transactional`, same HTTP helpers as other controller tests. |
| 126 | + |
| 127 | +Author: Morten (netroms) on the new file. |
| 128 | + |
| 129 | +Tests: |
| 130 | + |
| 131 | +1. **`testPatchOrganisationUnitNameKeepsChildren`** |
| 132 | + - Create parent + child OUs |
| 133 | + - `PATCH /organisationUnits/{parent}` replace `/name` |
| 134 | + - GET parent with `fields=name,children[id]` → new name; children size unchanged |
| 135 | + |
| 136 | +2. **`testPatchUserGroupNameKeepsUsers`** |
| 137 | + - Create group with a user member |
| 138 | + - `PATCH /userGroups/{id}` replace `/name` |
| 139 | + - GET `fields=name,users[id]` → new name; users size unchanged |
| 140 | + |
| 141 | +3. **`testPatchUserFirstNameKeepsUserGroups`** |
| 142 | + - User in a group |
| 143 | + - `PATCH /users/{id}?importReportMode=ERRORS` replace `/firstName` |
| 144 | + - GET `fields=firstName,userGroups[id]` → new firstName; userGroups size unchanged |
| 145 | + |
| 146 | +4. **`testPatchUserRoleUsersPathDoesNotChangeMembership`** |
| 147 | + - Role with one assigned user (via owner side / existing helpers) |
| 148 | + - `PATCH /userRoles/{id}` `replace /users` with `[]` |
| 149 | + - GET `fields=users[id]` → still 1 user (I5) |
| 150 | + - Accept 200 with possible ERRORS_NOT_OWNER reporting; membership must not drop |
| 151 | + |
| 152 | +Keep existing `UserControllerTest.testPatchUserRoleDescriptionKeepsUsers` on the PR (I4 for UserRole). |
| 153 | + |
| 154 | +### C. PR reply (after tests green) |
| 155 | + |
| 156 | +Reply to David covering: |
| 157 | + |
| 158 | +1. System-wide concern addressed by multi-entity integration + web tests (list invariants I1–I5). |
| 159 | +2. PUT: different path; no `JsonPatchManager`; not the same issue. |
| 160 | +3. Owner vs non-owner: UserGroup.users is owner and is not skipped; test proves members retained. |
| 161 | +4. Explicit `/users` on UserRole does not persist membership (owner is `User.userRoles` / membership sub-resource). |
| 162 | + |
| 163 | +## Implementation notes |
| 164 | + |
| 165 | +- Work on branch `fix-userrole-jsonpatch-lazy-members`. |
| 166 | +- Tests only unless a failure reveals a product bug. |
| 167 | +- Prefer entity factories already used in `JsonPatchManagerTest` / web-api bases (`createOrganisationUnit`, `makeUser`, `createUserRole`, `createDataElementGroup`, etc.). |
| 168 | +- For UserGroup managedBy relationship, use existing domain APIs if cheap; if setup is awkward, still assert owner `users` preservation and drop managedBy lazy assert rather than block the suite. |
| 169 | +- Do not use `git add -A`; stage specific paths. |
| 170 | +- Update root `CHANGELOG.md` if the project log is in use for this workstream; otherwise note in PR body only. |
| 171 | + |
| 172 | +## Acceptance criteria |
| 173 | + |
| 174 | +1. New integration tests cover OU, User, UserGroup, DataElementGroup as specified. |
| 175 | +2. New web-api class covers OU, UserGroup, User, UserRole `/users` no-op. |
| 176 | +3. All new tests pass locally (integration + web-api modules as applicable). |
| 177 | +4. Existing PR UserRole tests still pass. |
| 178 | +5. No production code change required for green tests (ideal). |
| 179 | +6. Design/plan docs committed; PR comment ready for David. |
| 180 | + |
| 181 | +## Out of scope follow-ups |
| 182 | + |
| 183 | +- Optional PUT `/userRoles` without `users` body keeps members (reply ammo only). |
| 184 | +- Fixing UserGroup owner lazy cost on PATCH (separate perf ticket if needed). |
| 185 | +- Simplifying Jackson filter mechanics (Jan; retracted for now). |
0 commit comments