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