3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
13 * Test class for CRM_Contact_BAO_GroupContact BAO
18 class CRM_Contact_BAO_GroupContactCacheTest
extends CiviUnitTestCase
{
21 * Manually add and remove contacts from a smart group.
23 * @throws \API_Exception
24 * @throws \CRM_Core_Exception
25 * @throws \CiviCRM_API3_Exception
27 public function testManualAddRemove(): void
{
28 [$group, $living, $deceased] = $this->setupSmartGroup();
31 $this->callAPISuccess('GroupContact', 'create', [
32 'contact_id' => $living[0]->id
,
33 'group_id' => $group->id
,
36 CRM_Contact_BAO_GroupContactCache
::load($group);
37 $this->assertCacheMatches(
38 [$deceased[0]->id
, $deceased[1]->id
, $deceased[2]->id
, $living[0]->id
],
43 $this->callAPISuccess('group_contact', 'create', [
44 'contact_id' => $deceased[0]->id
,
45 'group_id' => $group->id
,
46 'status' => 'Removed',
49 CRM_Contact_BAO_GroupContactCache
::load($group);
50 $this->assertCacheMatches(
61 * Allow removing contact from a parent group even if contact is in a child
64 * @throws \CRM_Core_Exception
66 public function testRemoveFromParentSmartGroup(): void
{
67 // Create $c1, $c2, $c3
68 $deceased = $this->createTestObject('CRM_Contact_DAO_Contact', ['is_deceased' => 1], 3);
70 // Create smart group $parent
72 'name' => 'Deceased Contacts',
73 'title' => 'Deceased Contacts',
75 'formValues' => ['is_deceased' => 1],
77 $parent = CRM_Contact_BAO_Group
::createSmartGroup($params);
78 $this->registerTestObjects([$parent]);
80 // Create group $child in $parent
82 'name' => 'Child Group',
83 'title' => 'Child Group',
85 'parents' => [$parent->id
=> 1],
87 $child = CRM_Contact_BAO_Group
::create($params);
88 $this->registerTestObjects([$child]);
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
,
98 CRM_Contact_BAO_GroupContactCache
::load($parent);
99 $this->assertCacheMatches(
100 [$deceased[0]->id
, $deceased[1]->id
, $deceased[2]->id
],
104 // Remove $c1 from $parent
105 $this->callAPISuccess('GroupContact', 'create', [
106 'contact_id' => $deceased[0]->id
,
107 'group_id' => $parent->id
,
108 'status' => 'Removed',
111 // Assert $c1 not in $parent
112 CRM_Contact_BAO_GroupContactCache
::load($parent);
113 $this->assertCacheMatches(
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',
125 1 => [$child->id
, 'Integer'],
126 2 => [$deceased[0]->id
, 'Integer'],
127 3 => ['Added', 'String'],
133 * Assert that the cache for a group contains exactly the listed contacts.
135 * @param array $expectedContactIds
137 * @param int $groupId
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
;
148 sort($expectedContactIds);
149 sort($actualContactIds);
150 $this->assertEquals($expectedContactIds, $actualContactIds);
154 * Test the opportunistic refresh cache function does not touch non-expired entries.
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
],
163 CRM_Contact_BAO_GroupContactCache
::opportunisticCacheFlush();
165 $this->assertCacheNotRefreshed($deceased, $group);
169 * Test the opportunistic refresh cache function does refresh stale entries.
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
);
176 Civi
::$statics['CRM_Contact_BAO_GroupContactCache']['is_refresh_init'] = FALSE;
178 CRM_Contact_BAO_GroupContactCache
::opportunisticCacheFlush();
180 $this->assertCacheRefreshed($group);
184 * Test the opportunistic refresh cache function does refresh expired entries if mode is deterministic.
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']);
197 * Test the deterministic cache function refreshes with the deterministic setting.
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']);
210 * Test the deterministic cache function refresh doesn't mess up non-expired.
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']);
222 * Test the deterministic cache function refreshes with the opportunistic setting.
224 * (hey it's an opportunity!).
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);
236 * Test the api job wrapper around the deterministic refresh works.
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);
247 // *** Everything below this should be moved to parent class ****
251 * (DAO_Name => array(int)) List of items to garbage-collect during tearDown
253 private $_testObjects;
256 * Tears down the fixture, for example, closes a network connection.
258 * This method is called after a test is executed.
260 protected function tearDown(): void
{
262 $this->deleteTestObjects();
266 * This is a wrapper for CRM_Core_DAO::createTestObject which tracks created entities.
268 * @see CRM_Core_DAO::createTestObject
270 * @param string $daoName
271 * @param array $params
272 * @param int $numObjects
273 * @param bool $createOnly
275 * @return array|NULL|object
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);
283 $this->registerTestObjects([$objects]);
289 * Register test objects.
291 * @param array $objects
292 * DAO or BAO objects.
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
;
302 * Delete test objects.
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"
310 public function deleteTestObjects() {
311 foreach ($this->_testObjects
as $daoName => $daoIds) {
312 foreach ($daoIds as $daoId) {
313 CRM_Core_DAO
::deleteTestObjects($daoName, ['id' => $daoId]);
316 $this->_testObjects
= [];
320 * Set up a smart group testing scenario.
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);
332 'name' => 'Deceased Contacts',
333 'title' => 'Deceased Contacts',
335 'formValues' => ['is_deceased' => 1],
337 $group = CRM_Contact_BAO_Group
::createSmartGroup($params);
338 $this->registerTestObjects([$group]);
340 // Assert: $g cache has exactly $y1, $y2, $y3
341 CRM_Contact_BAO_GroupContactCache
::load($group);
343 $this->assertCacheMatches(
344 [$deceased[0]->id
, $deceased[1]->id
, $deceased[2]->id
],
347 // Reload the group so we have the cache_date & refresh_date.
348 return [$group, $living, $deceased];
357 protected function assertCacheNotRefreshed($deceased, $group) {
358 $this->assertCacheMatches(
359 [$deceased[0]->id
, $deceased[1]->id
, $deceased[2]->id
],
362 $afterGroup = $this->callAPISuccessGetSingle('Group', ['id' => $group->id
]);
363 $this->assertEquals($group->cache_date
, $afterGroup['cache_date']);
367 * Make the cache for the group stale, resetting it to before the timeout period.
369 * @param CRM_Contact_BAO_Group $group
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
);
375 Civi
::$statics['CRM_Contact_BAO_GroupContactCache']['is_refresh_init'] = FALSE;
383 protected function assertCacheRefreshed($group) {
384 $this->assertCacheMatches(
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');
394 * Test Smart group search
396 public function testSmartGroupSearchBuilder() {
397 $returnProperties = [
399 'contact_sub_type' => 1,
403 [$group, $living, $deceased] = $this->setupSmartGroup();
406 'name' => 'Living Contacts',
407 'title' => 'Living Contacts',
409 'formValues' => ['is_deceased' => 0],
411 $group2 = CRM_Contact_BAO_Group
::createSmartGroup($params);
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
,
421 $ids = $query->searchQuery(0, 0, NULL,
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}");
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
,
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}");
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
,
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})");
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
,
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
]);
470 public function testMultipleGroupWhereClause() {
471 $returnProperties = [
473 'contact_sub_type' => 1,
477 [$group, $living, $deceased] = $this->setupSmartGroup();
480 'name' => 'Living Contacts',
481 'title' => 'Living Contacts',
483 'formValues' => ['is_deceased' => 0],
485 $group2 = CRM_Contact_BAO_Group
::createSmartGroup($params);
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
,
495 $ids = $query->searchQuery(0, 0, NULL,
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
);
513 * Check if contact ids are fetched correctly.
515 * @param object $query
516 * @param string $groupWhereClause
518 public function _assertContactIds($query, $groupWhereClause) {
519 $contactIds = explode(',', $query->searchQuery(0, 0, NULL,
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
;
528 $this->assertEquals(sort($expectedContactIds), sort($contactIds));