Merge pull request #20360 from eileenmcnaughton/ppp
[civicrm-core.git] / tests / phpunit / CRM / Contact / BAO / GroupContactCacheTest.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * Test class for CRM_Contact_BAO_GroupContact BAO
14 *
15 * @package CiviCRM
16 * @group headless
17 */
18 class CRM_Contact_BAO_GroupContactCacheTest extends CiviUnitTestCase {
19
20 /**
21 * Manually add and remove contacts from a smart group.
22 *
23 * @throws \API_Exception
24 * @throws \CRM_Core_Exception
25 * @throws \CiviCRM_API3_Exception
26 */
27 public function testManualAddRemove(): void {
28 [$group, $living, $deceased] = $this->setupSmartGroup();
29
30 // Add $n1 to $g
31 $this->callAPISuccess('GroupContact', 'create', [
32 'contact_id' => $living[0]->id,
33 'group_id' => $group->id,
34 ]);
35
36 CRM_Contact_BAO_GroupContactCache::load($group);
37 $this->assertCacheMatches(
38 [$deceased[0]->id, $deceased[1]->id, $deceased[2]->id, $living[0]->id],
39 $group->id
40 );
41
42 // Remove $y1 from $g
43 $this->callAPISuccess('group_contact', 'create', [
44 'contact_id' => $deceased[0]->id,
45 'group_id' => $group->id,
46 'status' => 'Removed',
47 ]);
48
49 CRM_Contact_BAO_GroupContactCache::load($group);
50 $this->assertCacheMatches(
51 [
52 $deceased[1]->id,
53 $deceased[2]->id,
54 $living[0]->id,
55 ],
56 $group->id
57 );
58 }
59
60 /**
61 * Allow removing contact from a parent group even if contact is in a child
62 * group. (CRM-8858).
63 *
64 * @throws \CRM_Core_Exception
65 */
66 public function testRemoveFromParentSmartGroup(): void {
67 // Create $c1, $c2, $c3
68 $deceased = $this->createTestObject('CRM_Contact_DAO_Contact', ['is_deceased' => 1], 3);
69
70 // Create smart group $parent
71 $params = [
72 'name' => 'Deceased Contacts',
73 'title' => 'Deceased Contacts',
74 'is_active' => 1,
75 'formValues' => ['is_deceased' => 1],
76 ];
77 $parent = CRM_Contact_BAO_Group::createSmartGroup($params);
78 $this->registerTestObjects([$parent]);
79
80 // Create group $child in $parent
81 $params = [
82 'name' => 'Child Group',
83 'title' => 'Child Group',
84 'is_active' => 1,
85 'parents' => [$parent->id => 1],
86 ];
87 $child = CRM_Contact_BAO_Group::create($params);
88 $this->registerTestObjects([$child]);
89
90 // Add $c1, $c2, $c3 to $child
91 foreach ($deceased as $contact) {
92 $this->callAPISuccess('group_contact', 'create', [
93 'contact_id' => $contact->id,
94 'group_id' => $child->id,
95 ]);
96 }
97
98 CRM_Contact_BAO_GroupContactCache::load($parent);
99 $this->assertCacheMatches(
100 [$deceased[0]->id, $deceased[1]->id, $deceased[2]->id],
101 $parent->id
102 );
103
104 // Remove $c1 from $parent
105 $this->callAPISuccess('GroupContact', 'create', [
106 'contact_id' => $deceased[0]->id,
107 'group_id' => $parent->id,
108 'status' => 'Removed',
109 ]);
110
111 // Assert $c1 not in $parent
112 CRM_Contact_BAO_GroupContactCache::load($parent);
113 $this->assertCacheMatches(
114 [
115 $deceased[1]->id,
116 $deceased[2]->id,
117 ],
118 $parent->id
119 );
120
121 // Assert $c1 still in $child
122 $this->assertDBQuery(1,
123 'select count(*) from civicrm_group_contact where group_id=%1 and contact_id=%2 and status=%3',
124 [
125 1 => [$child->id, 'Integer'],
126 2 => [$deceased[0]->id, 'Integer'],
127 3 => ['Added', 'String'],
128 ]
129 );
130 }
131
132 /**
133 * Assert that the cache for a group contains exactly the listed contacts.
134 *
135 * @param array $expectedContactIds
136 * Array(int).
137 * @param int $groupId
138 */
139 public function assertCacheMatches($expectedContactIds, $groupId): void {
140 $sql = 'SELECT contact_id FROM civicrm_group_contact_cache WHERE group_id = %1';
141 $params = [1 => [$groupId, 'Integer']];
142 $dao = CRM_Core_DAO::executeQuery($sql, $params);
143 $actualContactIds = [];
144 while ($dao->fetch()) {
145 $actualContactIds[] = $dao->contact_id;
146 }
147
148 sort($expectedContactIds);
149 sort($actualContactIds);
150 $this->assertEquals($expectedContactIds, $actualContactIds);
151 }
152
153 /**
154 * Test the opportunistic refresh cache function does not touch non-expired entries.
155 */
156 public function testOpportunisticRefreshCacheNoChangeIfNotExpired() {
157 [$group, $living, $deceased] = $this->setupSmartGroup();
158 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
159 $this->assertCacheMatches(
160 [$deceased[0]->id, $deceased[1]->id, $deceased[2]->id],
161 $group->id
162 );
163 CRM_Contact_BAO_GroupContactCache::opportunisticCacheFlush();
164
165 $this->assertCacheNotRefreshed($deceased, $group);
166 }
167
168 /**
169 * Test the opportunistic refresh cache function does refresh stale entries.
170 */
171 public function testOpportunisticRefreshChangeIfCacheDateFieldStale() {
172 [$group, $living, $deceased] = $this->setupSmartGroup();
173 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
174 CRM_Core_DAO::executeQuery('UPDATE civicrm_group SET cache_date = DATE_SUB(NOW(), INTERVAL 7 MINUTE) WHERE id = ' . $group->id);
175 $group->find(TRUE);
176 Civi::$statics['CRM_Contact_BAO_GroupContactCache']['is_refresh_init'] = FALSE;
177 sleep(1);
178 CRM_Contact_BAO_GroupContactCache::opportunisticCacheFlush();
179
180 $this->assertCacheRefreshed($group);
181 }
182
183 /**
184 * Test the opportunistic refresh cache function does refresh expired entries if mode is deterministic.
185 */
186 public function testOpportunisticRefreshNoChangeWithDeterministicSetting() {
187 [$group, $living, $deceased] = $this->setupSmartGroup();
188 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'deterministic']);
189 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
190 $this->makeCacheStale($group);
191 CRM_Contact_BAO_GroupContactCache::opportunisticCacheFlush();
192 $this->assertCacheNotRefreshed($deceased, $group);
193 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'opportunistic']);
194 }
195
196 /**
197 * Test the deterministic cache function refreshes with the deterministic setting.
198 */
199 public function testDeterministicRefreshChangeWithDeterministicSetting() {
200 [$group, $living, $deceased] = $this->setupSmartGroup();
201 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'deterministic']);
202 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
203 $this->makeCacheStale($group);
204 CRM_Contact_BAO_GroupContactCache::deterministicCacheFlush();
205 $this->assertCacheRefreshed($group);
206 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'opportunistic']);
207 }
208
209 /**
210 * Test the deterministic cache function refresh doesn't mess up non-expired.
211 */
212 public function testDeterministicRefreshChangeDoesNotTouchNonExpired() {
213 [$group, $living, $deceased] = $this->setupSmartGroup();
214 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'deterministic']);
215 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
216 CRM_Contact_BAO_GroupContactCache::deterministicCacheFlush();
217 $this->assertCacheNotRefreshed($deceased, $group);
218 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'opportunistic']);
219 }
220
221 /**
222 * Test the deterministic cache function refreshes with the opportunistic setting.
223 *
224 * (hey it's an opportunity!).
225 */
226 public function testDeterministicRefreshChangeWithOpportunisticSetting() {
227 [$group, $living, $deceased] = $this->setupSmartGroup();
228 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'opportunistic']);
229 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
230 $this->makeCacheStale($group);
231 CRM_Contact_BAO_GroupContactCache::deterministicCacheFlush();
232 $this->assertCacheRefreshed($group);
233 }
234
235 /**
236 * Test the api job wrapper around the deterministic refresh works.
237 */
238 public function testJobWrapper() {
239 [$group, $living, $deceased] = $this->setupSmartGroup();
240 $this->callAPISuccess('Setting', 'create', ['smart_group_cache_refresh_mode' => 'opportunistic']);
241 $this->callAPISuccess('Contact', 'create', ['id' => $deceased[0]->id, 'is_deceased' => 0]);
242 $this->makeCacheStale($group);
243 $this->callAPISuccess('Job', 'group_cache_flush', []);
244 $this->assertCacheRefreshed($group);
245 }
246
247 // *** Everything below this should be moved to parent class ****
248
249 /**
250 * @var array
251 * (DAO_Name => array(int)) List of items to garbage-collect during tearDown
252 */
253 private $_testObjects;
254
255 /**
256 * Tears down the fixture, for example, closes a network connection.
257 *
258 * This method is called after a test is executed.
259 */
260 protected function tearDown(): void {
261 parent::tearDown();
262 $this->deleteTestObjects();
263 }
264
265 /**
266 * This is a wrapper for CRM_Core_DAO::createTestObject which tracks created entities.
267 *
268 * @see CRM_Core_DAO::createTestObject
269 *
270 * @param string $daoName
271 * @param array $params
272 * @param int $numObjects
273 * @param bool $createOnly
274 *
275 * @return array|NULL|object
276 */
277 public function createTestObject($daoName, $params = [], $numObjects = 1, $createOnly = FALSE) {
278 $objects = CRM_Core_DAO::createTestObject($daoName, $params, $numObjects, $createOnly);
279 if (is_array($objects)) {
280 $this->registerTestObjects($objects);
281 }
282 else {
283 $this->registerTestObjects([$objects]);
284 }
285 return $objects;
286 }
287
288 /**
289 * Register test objects.
290 *
291 * @param array $objects
292 * DAO or BAO objects.
293 */
294 public function registerTestObjects($objects) {
295 foreach ($objects as $object) {
296 $daoName = preg_replace('/_BAO_/', '_DAO_', get_class($object));
297 $this->_testObjects[$daoName][] = $object->id;
298 }
299 }
300
301 /**
302 * Delete test objects.
303 *
304 * Note: You might argue that the FK relations between test
305 * objects could make this problematic; however, it should
306 * behave intuitively as long as we mentally split our
307 * test-objects between the "manual/primary records"
308 * and the "automatic/secondary records"
309 */
310 public function deleteTestObjects() {
311 foreach ($this->_testObjects as $daoName => $daoIds) {
312 foreach ($daoIds as $daoId) {
313 CRM_Core_DAO::deleteTestObjects($daoName, ['id' => $daoId]);
314 }
315 }
316 $this->_testObjects = [];
317 }
318
319 /**
320 * Set up a smart group testing scenario.
321 *
322 * @return array
323 */
324 protected function setupSmartGroup(): array {
325 // Create contacts $y1, $y2, $y3 which do match $g; create $n1, $n2, $n3 which do not match $g
326 $living = $this->createTestObject('CRM_Contact_DAO_Contact', ['is_deceased' => 0], 3);
327 $deceased = $this->createTestObject('CRM_Contact_DAO_Contact', ['is_deceased' => 1], 3);
328 $this->assertCount(3, $deceased);
329 $this->assertCount(3, $living);
330
331 $params = [
332 'name' => 'Deceased Contacts',
333 'title' => 'Deceased Contacts',
334 'is_active' => 1,
335 'formValues' => ['is_deceased' => 1],
336 ];
337 $group = CRM_Contact_BAO_Group::createSmartGroup($params);
338 $this->registerTestObjects([$group]);
339
340 // Assert: $g cache has exactly $y1, $y2, $y3
341 CRM_Contact_BAO_GroupContactCache::load($group);
342 $group->find(TRUE);
343 $this->assertCacheMatches(
344 [$deceased[0]->id, $deceased[1]->id, $deceased[2]->id],
345 $group->id
346 );
347 // Reload the group so we have the cache_date & refresh_date.
348 return [$group, $living, $deceased];
349 }
350
351 /**
352 * @param $deceased
353 * @param $group
354 *
355 * @throws \Exception
356 */
357 protected function assertCacheNotRefreshed($deceased, $group) {
358 $this->assertCacheMatches(
359 [$deceased[0]->id, $deceased[1]->id, $deceased[2]->id],
360 $group->id
361 );
362 $afterGroup = $this->callAPISuccessGetSingle('Group', ['id' => $group->id]);
363 $this->assertEquals($group->cache_date, $afterGroup['cache_date']);
364 }
365
366 /**
367 * Make the cache for the group stale, resetting it to before the timeout period.
368 *
369 * @param CRM_Contact_BAO_Group $group
370 */
371 protected function makeCacheStale(&$group) {
372 CRM_Core_DAO::executeQuery('UPDATE civicrm_group SET cache_date = DATE_SUB(NOW(), INTERVAL 7 MINUTE) WHERE id = ' . $group->id);
373 unset($group->cache_date);
374 $group->find(TRUE);
375 Civi::$statics['CRM_Contact_BAO_GroupContactCache']['is_refresh_init'] = FALSE;
376 }
377
378 /**
379 * @param $group
380 *
381 * @throws \Exception
382 */
383 protected function assertCacheRefreshed($group) {
384 $this->assertCacheMatches(
385 [],
386 $group->id
387 );
388
389 $afterGroup = $this->callAPISuccessGetSingle('Group', ['id' => $group->id]);
390 $this->assertTrue(empty($afterGroup['cache_date']), 'cache date should not be set as the cache is not built');
391 }
392
393 /**
394 * Test Smart group search
395 */
396 public function testSmartGroupSearchBuilder() {
397 $returnProperties = [
398 'contact_type' => 1,
399 'contact_sub_type' => 1,
400 'sort_name' => 1,
401 'group' => 1,
402 ];
403 [$group, $living, $deceased] = $this->setupSmartGroup();
404
405 $params = [
406 'name' => 'Living Contacts',
407 'title' => 'Living Contacts',
408 'is_active' => 1,
409 'formValues' => ['is_deceased' => 0],
410 ];
411 $group2 = CRM_Contact_BAO_Group::createSmartGroup($params);
412
413 //Filter on smart group with =, !=, IN and NOT IN operator.
414 $params = [['group', '=', $group2->id, 1, 0]];
415 $query = new CRM_Contact_BAO_Query(
416 $params, $returnProperties,
417 NULL, FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTACTS,
418 FALSE,
419 FALSE, FALSE
420 );
421 $ids = $query->searchQuery(0, 0, NULL,
422 FALSE, FALSE, FALSE,
423 TRUE, FALSE
424 );
425 $key = $query->getGroupCacheTableKeys()[0];
426 $expectedWhere = "civicrm_group_contact_cache_{$key}.group_id IN (\"{$group2->id}\")";
427 $this->assertStringContainsString($expectedWhere, $query->_whereClause);
428 $this->_assertContactIds($query, "group_id = {$group2->id}");
429
430 $params = [['group', '!=', $group->id, 1, 0]];
431 $query = new CRM_Contact_BAO_Query(
432 $params, $returnProperties,
433 NULL, FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTACTS,
434 FALSE,
435 FALSE, FALSE
436 );
437 $key = $query->getGroupCacheTableKeys()[0];
438 //Assert if proper where clause is present.
439 $expectedWhere = "civicrm_group_contact_{$key}.group_id != {$group->id} AND civicrm_group_contact_cache_{$key}.group_id IS NULL OR ( civicrm_group_contact_cache_{$key}.contact_id NOT IN (SELECT contact_id FROM civicrm_group_contact_cache cgcc WHERE cgcc.group_id IN ( {$group->id} ) ) )";
440 $this->assertStringContainsString($expectedWhere, $query->_whereClause);
441 $this->_assertContactIds($query, "group_id != {$group->id}");
442
443 $params = [['group', 'IN', [$group->id, $group2->id], 1, 0]];
444 $query = new CRM_Contact_BAO_Query(
445 $params, $returnProperties,
446 NULL, FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTACTS,
447 FALSE,
448 FALSE, FALSE
449 );
450 $key = $query->getGroupCacheTableKeys()[0];
451 $expectedWhere = "civicrm_group_contact_cache_{$key}.group_id IN (\"{$group->id}\", \"{$group2->id}\")";
452 $this->assertStringContainsString($expectedWhere, $query->_whereClause);
453 $this->_assertContactIds($query, "group_id IN ({$group->id}, {$group2->id})");
454
455 $params = [['group', 'NOT IN', [$group->id], 1, 0]];
456 $query = new CRM_Contact_BAO_Query(
457 $params, $returnProperties,
458 NULL, FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTACTS,
459 FALSE,
460 FALSE, FALSE
461 );
462 $key = $query->getGroupCacheTableKeys()[0];
463 $expectedWhere = "civicrm_group_contact_{$key}.group_id NOT IN ( {$group->id} ) AND civicrm_group_contact_cache_{$key}.group_id IS NULL OR ( civicrm_group_contact_cache_{$key}.contact_id NOT IN (SELECT contact_id FROM civicrm_group_contact_cache cgcc WHERE cgcc.group_id IN ( {$group->id} ) ) )";
464 $this->assertStringContainsString($expectedWhere, $query->_whereClause);
465 $this->_assertContactIds($query, "group_id NOT IN ({$group->id})");
466 $this->callAPISuccess('group', 'delete', ['id' => $group->id]);
467 $this->callAPISuccess('group', 'delete', ['id' => $group2->id]);
468 }
469
470 public function testMultipleGroupWhereClause() {
471 $returnProperties = [
472 'contact_type' => 1,
473 'contact_sub_type' => 1,
474 'sort_name' => 1,
475 'group' => 1,
476 ];
477 [$group, $living, $deceased] = $this->setupSmartGroup();
478
479 $params = [
480 'name' => 'Living Contacts',
481 'title' => 'Living Contacts',
482 'is_active' => 1,
483 'formValues' => ['is_deceased' => 0],
484 ];
485 $group2 = CRM_Contact_BAO_Group::createSmartGroup($params);
486
487 //Filter on smart group with =, !=, IN and NOT IN operator.
488 $params = [['group', '=', $group2->id, 1, 0], ['group', '=', $group->id, 1, 0]];
489 $query = new CRM_Contact_BAO_Query(
490 $params, $returnProperties,
491 NULL, FALSE, FALSE, CRM_Contact_BAO_Query::MODE_CONTACTS,
492 FALSE,
493 FALSE, FALSE
494 );
495 $ids = $query->searchQuery(0, 0, NULL,
496 FALSE, FALSE, FALSE,
497 TRUE, FALSE
498 );
499 $key1 = $query->getGroupCacheTableKeys()[0];
500 $key2 = $query->getGroupCacheTableKeys()[1];
501 $expectedWhere = 'civicrm_group_contact_cache_' . $key1 . '.group_id IN ("' . $group2->id . '") ) ) AND ( ( civicrm_group_contact_cache_' . $key2 . '.group_id IN ("' . $group->id . '")';
502 $this->assertStringContainsString($expectedWhere, $query->_whereClause);
503 // Check that we have 3 joins to the group contact cache 1 for each of the group where clauses and 1 for the fact we are returning groups in the select.
504 $expectedFrom1 = 'LEFT JOIN civicrm_group_contact_cache civicrm_group_contact_cache_' . $key1 . ' ON contact_a.id = civicrm_group_contact_cache_' . $key1 . '.contact_id';
505 $this->assertStringContainsString($expectedFrom1, $query->_fromClause);
506 $expectedFrom2 = 'LEFT JOIN civicrm_group_contact_cache civicrm_group_contact_cache_' . $key2 . ' ON contact_a.id = civicrm_group_contact_cache_' . $key2 . '.contact_id';
507 $this->assertStringContainsString($expectedFrom2, $query->_fromClause);
508 $expectedFrom3 = 'LEFT JOIN civicrm_group_contact_cache ON contact_a.id = civicrm_group_contact_cache.contact_id';
509 $this->assertStringContainsString($expectedFrom3, $query->_fromClause);
510 }
511
512 /**
513 * Check if contact ids are fetched correctly.
514 *
515 * @param object $query
516 * @param string $groupWhereClause
517 */
518 public function _assertContactIds($query, $groupWhereClause) {
519 $contactIds = explode(',', $query->searchQuery(0, 0, NULL,
520 FALSE, FALSE, FALSE,
521 TRUE, FALSE
522 ));
523 $expectedContactIds = [];
524 $groupDAO = CRM_Core_DAO::executeQuery("SELECT contact_id FROM civicrm_group_contact_cache WHERE {$groupWhereClause}");
525 while ($groupDAO->fetch()) {
526 $expectedContactIds[] = $groupDAO->contact_id;
527 }
528 $this->assertEquals(sort($expectedContactIds), sort($contactIds));
529 }
530
531 }