4 * Class CRM_Dedupe_DedupeMergerTest
8 class CRM_Dedupe_MergerTest
extends CiviUnitTestCase
{
12 protected $_contactIds = [];
15 * Contacts created for the test.
17 * Overlaps contactIds....
21 protected $contacts = [];
26 * @throws \CRM_Core_Exception
28 public function tearDown(): void
{
31 'civicrm_group_contact',
33 'civicrm_prevnext_cache',
39 * @throws \CRM_Core_Exception
41 public function createDupeContacts(): void
{
42 // create a group to hold contacts, so that dupe checks don't consider any other contacts in the DB
44 'name' => 'Test Dupe Merger Group',
45 'title' => 'Test Dupe Merger Group',
48 'visibility' => 'Public Pages',
51 $result = $this->callAPISuccess('group', 'create', $params);
52 $this->_groupId
= $result['id'];
56 // make dupe checks based on based on following contact sets:
57 // FIRST - LAST - EMAIL
58 // ---------------------------------
59 // robin - hood - robin@example.com
60 // robin - hood - robin@example.com
61 // robin - hood - hood@example.com
62 // robin - dale - robin@example.com
63 // little - dale - dale@example.com
64 // little - dale - dale@example.com
65 // will - dale - dale@example.com
66 // will - dale - will@example.com
67 // will - dale - will@example.com
70 'first_name' => 'robin',
71 'last_name' => 'hood',
72 'email' => 'robin@example.com',
73 'contact_type' => 'Individual',
76 'first_name' => 'robin',
77 'last_name' => 'hood',
78 'email' => 'robin@example.com',
79 'contact_type' => 'Individual',
82 'first_name' => 'robin',
83 'last_name' => 'hood',
84 'email' => 'hood@example.com',
85 'contact_type' => 'Individual',
88 'first_name' => 'robin',
89 'last_name' => 'dale',
90 'email' => 'robin@example.com',
91 'contact_type' => 'Individual',
94 'first_name' => 'little',
95 'last_name' => 'dale',
96 'email' => 'dale@example.com',
97 'contact_type' => 'Individual',
100 'first_name' => 'little',
101 'last_name' => 'dale',
102 'email' => 'dale@example.com',
103 'contact_type' => 'Individual',
106 'first_name' => 'will',
107 'last_name' => 'dale',
108 'email' => 'dale@example.com',
109 'contact_type' => 'Individual',
112 'first_name' => 'will',
113 'last_name' => 'dale',
114 'email' => 'will@example.com',
115 'contact_type' => 'Individual',
118 'first_name' => 'will',
119 'last_name' => 'dale',
120 'email' => 'will@example.com',
121 'contact_type' => 'Individual',
126 foreach ($params as $param) {
127 $param['version'] = 3;
128 $contact = civicrm_api('contact', 'create', $param);
129 $this->_contactIds
[$count++
] = $contact['id'];
132 'contact_id' => $contact['id'],
133 'group_id' => $this->_groupId
,
136 $this->callAPISuccess('group_contact', 'create', $grpParams);
141 * Delete all created contacts.
143 public function deleteDupeContacts(): void
{
144 foreach ($this->_contactIds
as $contactId) {
145 $this->contactDelete($contactId);
147 $this->groupDelete($this->_groupId
);
151 * Test the batch merge.
153 * @throws \API_Exception
154 * @throws \CRM_Core_Exception
155 * @throws \CiviCRM_API3_Exception
157 public function testBatchMergeSelectedDuplicates(): void
{
158 $this->createDupeContacts();
160 // verify that all contacts have been created separately
161 $this->assertEquals(count($this->_contactIds
), 9, 'Check for number of contacts.');
163 $dao = new CRM_Dedupe_DAO_RuleGroup();
164 $dao->contact_type
= 'Individual';
165 $dao->name
= 'IndividualSupervised';
166 $dao->is_default
= 1;
169 $foundDupes = CRM_Dedupe_Finder
::dupesInGroup($dao->id
, $this->_groupId
);
171 // -------------------------------------------------------------------------
172 // Name and Email (reserved) Matches ( 3 pairs )
173 // --------------------------------------------------------------------------
174 // robin - hood - robin@example.com
175 // robin - hood - robin@example.com
176 // little - dale - dale@example.com
177 // little - dale - dale@example.com
178 // will - dale - will@example.com
179 // will - dale - will@example.com
180 // so 3 pairs for - first + last + mail
181 $this->assertEquals(count($foundDupes), 3, 'Check Individual-Supervised dupe rule for dupesInGroup().');
183 // Run dedupe finder as the browser would
184 //avoid invalid key error
185 $_SERVER['REQUEST_METHOD'] = 'GET';
186 $object = new CRM_Contact_Page_DedupeFind();
187 $object->set('gid', $this->_groupId
);
188 $object->set('rgid', $dao->id
);
189 $object->set('action', CRM_Core_Action
::UPDATE
);
190 $object->setEmbedded(TRUE);
193 // Retrieve pairs from prev next cache table
194 $select = ['pn.is_selected' => 'is_selected'];
195 $cacheKeyString = CRM_Dedupe_Merger
::getMergeCacheKeyString($dao->id
, $this->_groupId
, [], TRUE, 0);
196 $pnDupePairs = CRM_Core_BAO_PrevNextCache
::retrieve($cacheKeyString, NULL, NULL, 0, 0, $select);
197 $this->assertEquals(count($foundDupes), count($pnDupePairs), 'Check number of dupe pairs in prev next cache.');
199 // mark first two pairs as selected
200 CRM_Core_DAO
::singleValueQuery("UPDATE civicrm_prevnext_cache SET is_selected = 1 WHERE id IN ({$pnDupePairs[0]['prevnext_id']}, {$pnDupePairs[1]['prevnext_id']})");
202 $pnDupePairs = CRM_Core_BAO_PrevNextCache
::retrieve($cacheKeyString, NULL, NULL, 0, 0, $select);
203 $this->assertEquals(1, $pnDupePairs[0]['is_selected'], 'Check if first record in dupe pairs is marked as selected.');
204 $this->assertEquals(1, $pnDupePairs[0]['is_selected'], 'Check if second record in dupe pairs is marked as selected.');
206 // batch merge selected dupes
207 $result = CRM_Dedupe_Merger
::batchMerge($dao->id
, $this->_groupId
, 'safe', 5, 1);
208 $this->assertEquals(count($result['merged']), 2, 'Check number of merged pairs.');
210 $stats = $this->callAPISuccess('Dedupe', 'getstatistics', [
211 'group_id' => $this->_groupId
,
212 'rule_group_id' => $dao->id
,
213 'check_permissions' => TRUE,
215 $this->assertEquals(['merged' => 2, 'skipped' => 0], $stats);
217 // retrieve pairs from prev next cache table
218 $pnDupePairs = CRM_Core_BAO_PrevNextCache
::retrieve($cacheKeyString, NULL, NULL, 0, 0, $select);
219 $this->assertEquals(count($pnDupePairs), 1, 'Check number of remaining dupe pairs in prev next cache.');
221 $this->deleteDupeContacts();
225 * Test the batch merge.
227 public function testBatchMergeAllDuplicates() {
228 $this->createDupeContacts();
230 // verify that all contacts have been created separately
231 $this->assertEquals(count($this->_contactIds
), 9, 'Check for number of contacts.');
233 $dao = new CRM_Dedupe_DAO_RuleGroup();
234 $dao->contact_type
= 'Individual';
235 $dao->name
= 'IndividualSupervised';
236 $dao->is_default
= 1;
239 $foundDupes = CRM_Dedupe_Finder
::dupesInGroup($dao->id
, $this->_groupId
);
241 // -------------------------------------------------------------------------
242 // Name and Email (reserved) Matches ( 3 pairs )
243 // --------------------------------------------------------------------------
244 // robin - hood - robin@example.com
245 // robin - hood - robin@example.com
246 // little - dale - dale@example.com
247 // little - dale - dale@example.com
248 // will - dale - will@example.com
249 // will - dale - will@example.com
250 // so 3 pairs for - first + last + mail
251 $this->assertEquals(count($foundDupes), 3, 'Check Individual-Supervised dupe rule for dupesInGroup().');
253 // Run dedupe finder as the browser would
254 //avoid invalid key error
255 $_SERVER['REQUEST_METHOD'] = 'GET';
256 $object = new CRM_Contact_Page_DedupeFind();
257 $object->set('gid', $this->_groupId
);
258 $object->set('rgid', $dao->id
);
259 $object->set('action', CRM_Core_Action
::UPDATE
);
260 $object->setEmbedded(TRUE);
263 // Retrieve pairs from prev next cache table
264 $select = ['pn.is_selected' => 'is_selected'];
265 $cacheKeyString = CRM_Dedupe_Merger
::getMergeCacheKeyString($dao->id
, $this->_groupId
, [], TRUE, 0);
266 $pnDupePairs = CRM_Core_BAO_PrevNextCache
::retrieve($cacheKeyString, NULL, NULL, 0, 0, $select);
268 $this->assertEquals(count($foundDupes), count($pnDupePairs), 'Check number of dupe pairs in prev next cache.');
270 // batch merge all dupes
271 $result = CRM_Dedupe_Merger
::batchMerge($dao->id
, $this->_groupId
, 'safe', 5, 2);
272 $this->assertEquals(count($result['merged']), 3, 'Check number of merged pairs.');
274 $stats = $this->callAPISuccess('Dedupe', 'getstatistics', [
275 'rule_group_id' => $dao->id
,
276 'group_id' => $this->_groupId
,
278 // retrieve pairs from prev next cache table
279 $pnDupePairs = CRM_Core_BAO_PrevNextCache
::retrieve($cacheKeyString, NULL, NULL, 0, 0, $select);
280 $this->assertEquals(count($pnDupePairs), 0, 'Check number of remaining dupe pairs in prev next cache.');
282 $this->deleteDupeContacts();
286 * The goal of this function is to test that all required tables are returned.
288 public function testGetCidRefs(): void
{
289 $sortRefs = function($a) {
291 foreach ($a as &$fields) {
297 $this->entityCustomGroupWithSingleFieldCreate(__FUNCTION__
, 'Contacts');
299 // These are deliberately unset.
300 $unsetRefs = array_fill_keys(['civicrm_group_contact_cache', 'civicrm_acl_cache', 'civicrm_acl_contact_cache'], 1);
301 $this->assertEquals($sortRefs(array_diff_key($this->getStaticCIDRefs(), $unsetRefs)), $sortRefs(CRM_Dedupe_Merger
::cidRefs()));
302 $this->assertEquals($sortRefs(array_diff_key($this->getCalculatedCIDRefs(), $unsetRefs)), $sortRefs(CRM_Dedupe_Merger
::cidRefs()));
304 // These are deliberately unset.
305 // $unsetRefs = array_fill_keys(['civicrm_group_contact_cache', 'civicrm_acl_cache', 'civicrm_acl_contact_cache', 'civicrm_relationship_cache'], 1);
306 // $this->assertEquals(array_diff_key($this->getStaticCIDRefs(), $unsetRefs), CRM_Dedupe_Merger::cidRefs());
307 // $this->assertEquals(array_diff_key($this->getCalculatedCIDRefs(), $unsetRefs), CRM_Dedupe_Merger::cidRefs());
311 * Test function that gets duplicate pairs.
313 * It turns out there are 2 code paths retrieving this data so my initial
314 * focus is on ensuring they match.
316 * @throws \CRM_Core_Exception
318 public function testGetMatches(): void
{
319 $this->setupMatchData();
321 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
322 'rule_group_id' => 1,
324 $this->assertEquals([
326 'srcID' => $this->contacts
[1]['id'],
327 'srcName' => 'Mr. Mickey Mouse II',
328 'dstID' => $this->contacts
[0]['id'],
329 'dstName' => 'Mr. Mickey Mouse II',
334 'srcID' => $this->contacts
[3]['id'],
335 'srcName' => 'Mr. Minnie Mouse II',
336 'dstID' => $this->contacts
[2]['id'],
337 'dstName' => 'Mr. Minnie Mouse II',
345 * Test function that gets duplicate pairs.
347 * It turns out there are 2 code paths retrieving this data so my initial
348 * focus is on ensuring they match.
350 * @dataProvider getBooleanDataProvider
352 * @param bool $isReverse
354 * @throws \CRM_Core_Exception
356 public function testGetMatchesExcludeDeleted(bool $isReverse): void
{
357 $this->setupMatchData();
358 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
359 'rule_group_id' => 1,
360 'check_permissions' => TRUE,
361 'criteria' => ['Contact' => ['id' => 'IS NOT NULL']],
363 $this->assertCount(2, $pairs);
364 $this->callAPISuccess('Contact', 'delete', ['id' => ($isReverse ?
$pairs[0]['dstID'] : $pairs[0]['srcID'])]);
365 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
366 'rule_group_id' => 1,
367 'check_permissions' => TRUE,
368 'criteria' => ['Contact' => ['id' => ['>' => 1]]],
370 $this->assertCount(1, $pairs);
374 * Test that location type is ignored when deduping by postal address.
376 * @throws \CRM_Core_Exception
377 * @throws \CiviCRM_API3_Exception
379 public function testGetMatchesIgnoreLocationType(): void
{
380 $contact1 = $this->individualCreate();
381 $contact2 = $this->individualCreate();
382 $this->callAPISuccess('address', 'create', [
383 'contact_id' => $contact1,
384 'state_province_id' => 1049,
385 'location_type_id' => 1,
387 $this->callAPISuccess('address', 'create', [
388 'contact_id' => $contact2,
389 'state_province_id' => 1049,
390 'location_type_id' => 2,
392 $ruleGroup = $this->createRuleGroup();
393 $this->callAPISuccess('Rule', 'create', [
394 'dedupe_rule_group_id' => $ruleGroup['id'],
395 'rule_table' => 'civicrm_address',
396 'rule_field' => 'state_province_id',
399 $dupeCount = $this->callAPISuccess('Dedupe', 'getduplicates', [
400 'rule_group_id' => $ruleGroup['id'],
402 $this->assertEquals(1, $dupeCount);
406 * Test results are returned when criteria are passed in.
408 * @throws \CRM_Core_Exception
409 * @throws \CiviCRM_API3_Exception
411 public function testGetMatchesCriteriaMatched(): void
{
412 $this->setupMatchData();
413 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
414 'rule_group_id' => 1,
415 'criteria' => ['contact' => ['id' => ['>' => 1]]],
417 $this->assertCount(2, $pairs);
421 * Test results are returned when criteria are passed in & limit is respected.
423 * @throws \CRM_Core_Exception
424 * @throws \CiviCRM_API3_Exception
426 public function testGetMatchesCriteriaMatchedWithLimit(): void
{
427 $this->setupMatchData();
428 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
429 'rule_group_id' => 1,
430 'criteria' => ['contact' => ['id' => ['>' => 1]]],
431 'options' => ['limit' => 1],
433 $this->assertCount(1, $pairs);
437 * Test results are returned when criteria are passed in & limit is
440 * @throws \CRM_Core_Exception
441 * @throws \CiviCRM_API3_Exception
443 public function testGetMatchesCriteriaMatchedWithSearchLimit(): void
{
444 $this->setupMatchData();
445 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
446 'rule_group_id' => 1,
447 'criteria' => ['contact' => ['id' => ['>' => 1]]],
450 $this->assertCount(1, $pairs);
454 * Test getting matches where there are no criteria.
456 * @throws \CRM_Core_Exception
457 * @throws \CiviCRM_API3_Exception
459 public function testGetMatchesNoCriteria(): void
{
460 $this->setupMatchData();
461 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
462 'rule_group_id' => 1,
464 $this->assertCount(2, $pairs);
468 * Test getting matches with a limit in play.
470 * @throws \CRM_Core_Exception
471 * @throws \CiviCRM_API3_Exception
473 public function testGetMatchesNoCriteriaButLimit(): void
{
474 $this->setupMatchData();
475 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
476 'rule_group_id' => 1,
477 'options' => ['limit' => 1],
479 $this->assertCount(1, $pairs);
483 * Test that if criteria are passed and there are no matching contacts no matches are returned.
485 public function testGetMatchesCriteriaNotMatched() {
486 $this->setupMatchData();
487 $pairs = $this->callAPISuccess('Dedupe', 'getduplicates', [
488 'rule_group_id' => 1,
489 'criteria' => ['contact' => ['id' => ['>' => 100000]]],
491 $this->assertCount(0, $pairs);
495 * Test function that gets organization pairs.
497 * Note the rule will match on organization_name OR email - hence lots of
502 public function testGetOrganizationMatches(): void
{
503 $this->setupMatchData();
504 $ruleGroups = $this->callAPISuccessGetSingle('RuleGroup', [
505 'contact_type' => 'Organization',
506 'used' => 'Supervised',
509 $pairs = CRM_Dedupe_Merger
::getDuplicatePairs(
519 'srcID' => $this->contacts
[5]['id'],
520 'srcName' => 'Walt Disney Ltd',
521 'dstID' => $this->contacts
[4]['id'],
522 'dstName' => 'Walt Disney Ltd',
527 'srcID' => $this->contacts
[7]['id'],
528 'srcName' => 'Walt Disney',
529 'dstID' => $this->contacts
[6]['id'],
530 'dstName' => 'Walt Disney',
535 'srcID' => $this->contacts
[6]['id'],
536 'srcName' => 'Walt Disney',
537 'dstID' => $this->contacts
[4]['id'],
538 'dstName' => 'Walt Disney Ltd',
543 'srcID' => $this->contacts
[6]['id'],
544 'srcName' => 'Walt Disney',
545 'dstID' => $this->contacts
[5]['id'],
546 'dstName' => 'Walt Disney Ltd',
551 usort($pairs, [__CLASS__
, 'compareDupes']);
552 usort($expectedPairs, [__CLASS__
, 'compareDupes']);
553 $this->assertEquals($expectedPairs, $pairs);
557 * Function to sort $duplicate records in a stable way.
564 public static function compareDupes($a, $b) {
565 foreach (['srcName', 'dstName', 'srcID', 'dstID'] as $field) {
566 if ($a[$field] != $b[$field]) {
567 return ($a[$field] < $b[$field]) ?
1 : -1;
574 * Test function that gets organization duplicate pairs.
578 public function testGetOrganizationMatchesInGroup() {
579 $this->setupMatchData();
580 $ruleGroups = $this->callAPISuccessGetSingle('RuleGroup', [
581 'contact_type' => 'Organization',
582 'used' => 'Supervised',
585 $groupID = $this->groupCreate(['title' => 'she-mice']);
587 $this->callAPISuccess('GroupContact', 'create', [
588 'group_id' => $groupID,
589 'contact_id' => $this->contacts
[4]['id'],
592 $pairs = CRM_Dedupe_Merger
::getDuplicatePairs(
600 $this->assertEquals([
602 'srcID' => $this->contacts
[5]['id'],
603 'srcName' => 'Walt Disney Ltd',
604 'dstID' => $this->contacts
[4]['id'],
605 'dstName' => 'Walt Disney Ltd',
610 'srcID' => $this->contacts
[6]['id'],
611 'srcName' => 'Walt Disney',
612 'dstID' => $this->contacts
[4]['id'],
613 'dstName' => 'Walt Disney Ltd',
619 $this->callAPISuccess('GroupContact', 'create', [
620 'group_id' => $groupID,
621 'contact_id' => $this->contacts
[5]['id'],
623 CRM_Core_DAO
::executeQuery("DELETE FROM civicrm_prevnext_cache");
624 $pairs = CRM_Dedupe_Merger
::getDuplicatePairs(
632 $this->assertEquals([
634 'srcID' => $this->contacts
[5]['id'],
635 'srcName' => 'Walt Disney Ltd',
636 'dstID' => $this->contacts
[4]['id'],
637 'dstName' => 'Walt Disney Ltd',
642 'srcID' => $this->contacts
[6]['id'],
643 'srcName' => 'Walt Disney',
644 'dstID' => $this->contacts
[4]['id'],
645 'dstName' => 'Walt Disney Ltd',
650 'srcID' => $this->contacts
[6]['id'],
651 'srcName' => 'Walt Disney',
652 'dstID' => $this->contacts
[5]['id'],
653 'dstName' => 'Walt Disney Ltd',
661 * Test function that gets duplicate pairs.
663 * It turns out there are 2 code paths retrieving this data so my initial
664 * focus is on ensuring they match.
666 public function testGetMatchesInGroup() {
667 $this->setupMatchData();
669 $groupID = $this->groupCreate(['title' => 'she-mice']);
671 $this->callAPISuccess('GroupContact', 'create', [
672 'group_id' => $groupID,
673 'contact_id' => $this->contacts
[3]['id'],
676 $pairs = CRM_Dedupe_Merger
::getDuplicatePairs(
684 $this->assertEquals([
686 'srcID' => $this->contacts
[3]['id'],
687 'srcName' => 'Mr. Minnie Mouse II',
688 'dstID' => $this->contacts
[2]['id'],
689 'dstName' => 'Mr. Minnie Mouse II',
697 * Test the special info handling is unchanged after cleanup.
699 * Note the handling is silly - we are testing to lock in over short term
700 * changes not to imply any contract on the function.
702 * @throws \CRM_Core_Exception
703 * @throws \CiviCRM_API3_Exception
705 public function testGetRowsElementsAndInfoSpecialInfo() {
706 $contact1 = $this->individualCreate([
707 'preferred_communication_method' => [],
708 'communication_style_id' => 'Familiar',
709 'prefix_id' => 'Mrs.',
710 'suffix_id' => 'III',
712 $contact2 = $this->individualCreate([
713 'preferred_communication_method' => [
717 'communication_style_id' => 'Formal',
718 'gender_id' => 'Female',
720 $rowsElementsAndInfo = CRM_Dedupe_Merger
::getRowsElementsAndInfo($contact1, $contact2);
721 $rows = $rowsElementsAndInfo['rows'];
722 $this->assertEquals([
725 'title' => 'Individual Prefix',
726 ], $rows['move_prefix_id']);
727 $this->assertEquals([
730 'title' => 'Individual Suffix',
731 ], $rows['move_suffix_id']);
732 $this->assertEquals([
736 ], $rows['move_gender_id']);
737 $this->assertEquals([
738 'main' => 'Familiar',
740 'title' => 'Communication Style',
741 ], $rows['move_communication_style_id']);
742 $this->assertEquals(1, $rowsElementsAndInfo['migration_info']['move_communication_style_id']);
743 $this->assertEquals([
745 'other' => 'SMS, Fax',
746 'title' => 'Preferred Communication Method',
747 ], $rows['move_preferred_communication_method']);
748 $this->assertEquals('\ 14\ 15\ 1', $rowsElementsAndInfo['migration_info']['move_preferred_communication_method']);
752 * Test migration of Membership.
754 public function testMergeMembership() {
756 $this->setupMatchData();
757 $originalContactID = $this->contacts
[0]['id'];
758 $duplicateContactID = $this->contacts
[1]['id'];
760 //Add Membership for the duplicate contact.
761 $memTypeId = $this->membershipTypeCreate();
762 $this->callAPISuccess('Membership', 'create', [
763 'membership_type_id' => $memTypeId,
764 'contact_id' => $duplicateContactID,
766 //Assert if 'add new' checkbox is enabled on the merge form.
767 $rowsElementsAndInfo = CRM_Dedupe_Merger
::getRowsElementsAndInfo($originalContactID, $duplicateContactID);
768 foreach ($rowsElementsAndInfo['elements'] as $element) {
769 if (!empty($element[3]) && $element[3] == 'add new') {
770 $checkedAttr = ['checked' => 'checked'];
771 $this->checkArrayEquals($element[4], $checkedAttr);
775 //Merge and move the mem to the main contact.
776 $this->mergeContacts($originalContactID, $duplicateContactID, [
777 'move_rel_table_memberships' => 1,
778 'operation' => ['move_rel_table_memberships' => ['add' => 1]],
781 //Check if membership is correctly transferred to original contact.
782 $originalContactMembership = $this->callAPISuccess('Membership', 'get', [
783 'membership_type_id' => $memTypeId,
784 'contact_id' => $originalContactID,
786 $this->assertEquals(1, $originalContactMembership['count']);
790 * CRM-19653 : Test that custom field data should/shouldn't be overridden on
791 * selecting/not selecting option to migrate data respectively
793 * @throws \CRM_Core_Exception
795 public function testCustomDataOverwrite() {
796 // Create Custom Field
797 $createGroup = $this->setupCustomGroupForIndividual();
798 $createField = $this->setupCustomField('Graduation', $createGroup);
799 $customFieldName = 'custom_' . $createField['id'];
802 $this->setupMatchData();
804 $originalContactID = $this->contacts
[0]['id'];
805 // used as duplicate contact in 1st use-case
806 $duplicateContactID1 = $this->contacts
[1]['id'];
807 // used as duplicate contact in 2nd use-case
808 $duplicateContactID2 = $this->contacts
[2]['id'];
810 // update the text custom field for original contact with value 'abc'
811 $this->callAPISuccess('Contact', 'create', [
812 'id' => $originalContactID,
813 $customFieldName => 'abc',
815 $this->assertCustomFieldValue($originalContactID, 'abc', $customFieldName);
817 // update the text custom field for duplicate contact 1 with value 'def'
818 $this->callAPISuccess('Contact', 'create', [
819 'id' => $duplicateContactID1,
820 "{$customFieldName}" => 'def',
822 $this->assertCustomFieldValue($duplicateContactID1, 'def', $customFieldName);
824 // update the text custom field for duplicate contact 2 with value 'ghi'
825 $this->callAPISuccess('Contact', 'create', [
826 'id' => $duplicateContactID2,
827 "{$customFieldName}" => 'ghi',
829 $this->assertCustomFieldValue($duplicateContactID2, 'ghi', $customFieldName);
831 /*** USE-CASE 1: DO NOT OVERWRITE CUSTOM FIELD VALUE **/
832 $this->mergeContacts($originalContactID, $duplicateContactID1, [
833 "move_{$customFieldName}" => NULL,
835 $this->assertCustomFieldValue($originalContactID, 'abc', $customFieldName);
837 /*** USE-CASE 2: OVERWRITE CUSTOM FIELD VALUE **/
838 $this->mergeContacts($originalContactID, $duplicateContactID2, [
839 "move_{$customFieldName}" => 'ghi',
841 $this->assertCustomFieldValue($originalContactID, 'ghi', $customFieldName);
843 // cleanup created custom set
844 $this->callAPISuccess('CustomField', 'delete', ['id' => $createField['id']]);
845 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $createGroup['id']]);
849 * Creatd Date merge cases
852 public function createdDateMergeCases() {
854 // Normal pattern merge into the lower id
856 // Check if we flipped the contacts that it still does right thing
862 * dev/core#996 Ensure that the oldest created date is retained even if duplicates have been flipped
864 * @dataProvider createdDateMergeCases
866 * @param $keepContactKey
867 * @param $duplicateContactKey
869 * @throws \API_Exception
870 * @throws \CRM_Core_Exception
871 * @throws \CiviCRM_API3_Exception
872 * @throws \Civi\API\Exception\UnauthorizedException
874 public function testCreatedDatePostMerge($keepContactKey, $duplicateContactKey) {
875 $this->setupMatchData();
876 $lowerContactCreatedDate = $this->callAPISuccess('Contact', 'getsingle', [
877 'id' => $this->contacts
[0]['id'],
878 'return' => ['created_date'],
880 // Assume contacts have been flipped in the UL so merging into the higher id
881 $this->mergeContacts($this->contacts
[$keepContactKey]['id'], $this->contacts
[$duplicateContactKey]['id'], []);
882 $this->assertEquals($lowerContactCreatedDate, $this->callAPISuccess('Contact', 'getsingle', ['id' => $this->contacts
[$keepContactKey]['id'], 'return' => ['created_date']])['created_date']);
886 * Verifies that when a contact with a custom field value is merged into a
887 * contact without a record int its corresponding custom group table, and none
888 * of the custom fields of that custom table are selected, the value is not
891 public function testMigrationOfUnselectedCustomDataOnEmptyCustomRecord() {
892 // Create Custom Fields
893 $createGroup = $this->setupCustomGroupForIndividual();
894 $customField1 = $this->setupCustomField('TestField', $createGroup);
896 // Create multi-value custom field
897 $multiGroup = $this->CustomGroupMultipleCreateByParams();
898 $multiField = $this->customFieldCreate([
899 'custom_group_id' => $multiGroup['id'],
900 'label' => 'field_1' . $multiGroup['id'],
905 $this->setupMatchData();
906 $originalContactID = $this->contacts
[0]['id'];
907 $duplicateContactID = $this->contacts
[1]['id'];
909 // Update the text custom fields for duplicate contact
910 $this->callAPISuccess('Contact', 'create', [
911 'id' => $duplicateContactID,
912 "custom_{$customField1['id']}" => 'abc',
913 "custom_{$multiField['id']}" => 'def',
915 $this->assertCustomFieldValue($duplicateContactID, 'abc', "custom_{$customField1['id']}");
916 $this->assertCustomFieldValue($duplicateContactID, 'def', "custom_{$multiField['id']}");
918 // Merge, and ensure that no value was migrated
919 $this->mergeContacts($originalContactID, $duplicateContactID, [
920 "move_custom_{$customField1['id']}" => NULL,
921 "move_rel_table_custom_{$multiGroup['id']}" => NULL,
923 $this->assertCustomFieldValue($originalContactID, '', "custom_{$customField1['id']}");
924 $this->assertCustomFieldValue($originalContactID, '', "custom_{$multiField['id']}");
926 // cleanup created custom set
927 $this->callAPISuccess('CustomField', 'delete', ['id' => $customField1['id']]);
928 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $createGroup['id']]);
929 $this->callAPISuccess('CustomField', 'delete', ['id' => $multiField['id']]);
930 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $multiGroup['id']]);
934 * Tests that if only part of the custom fields of a custom group are selected
935 * for a merge, only those values are merged, while all other fields of the
936 * custom group retain their original value, specifically for a contact with
937 * no records on the custom group table.
939 * @throws \CRM_Core_Exception
940 * @throws \CiviCRM_API3_Exception
942 public function testMigrationOfSomeCustomDataOnEmptyCustomRecord() {
943 // Create Custom Fields
944 $createGroup = $this->setupCustomGroupForIndividual();
945 $customField1 = $this->setupCustomField('Test1', $createGroup);
946 $customField2 = $this->setupCustomField('Test2', $createGroup);
948 // Create multi-value custom field
949 $multiGroup = $this->CustomGroupMultipleCreateByParams();
950 $multiField = $this->customFieldCreate([
951 'custom_group_id' => $multiGroup['id'],
952 'label' => 'field_1' . $multiGroup['id'],
957 $this->setupMatchData();
958 $originalContactID = $this->contacts
[0]['id'];
959 $duplicateContactID = $this->contacts
[1]['id'];
961 // Update the text custom fields for duplicate contact
962 $this->callAPISuccess('Contact', 'create', [
963 'id' => $duplicateContactID,
964 "custom_{$customField1['id']}" => 'abc',
965 "custom_{$customField2['id']}" => 'def',
966 "custom_{$multiField['id']}" => 'ghi',
968 $this->assertCustomFieldValue($duplicateContactID, 'abc', "custom_{$customField1['id']}");
969 $this->assertCustomFieldValue($duplicateContactID, 'def', "custom_{$customField2['id']}");
970 $this->assertCustomFieldValue($duplicateContactID, 'ghi', "custom_{$multiField['id']}");
973 $this->mergeContacts($originalContactID, $duplicateContactID, [
974 "move_custom_{$customField1['id']}" => NULL,
975 "move_custom_{$customField2['id']}" => 'def',
976 "move_rel_table_custom_{$multiGroup['id']}" => '1',
978 $this->assertCustomFieldValue($originalContactID, '', "custom_{$customField1['id']}");
979 $this->assertCustomFieldValue($originalContactID, 'def', "custom_{$customField2['id']}");
980 $this->assertCustomFieldValue($originalContactID, 'ghi', "custom_{$multiField['id']}");
982 // cleanup created custom set
983 $this->callAPISuccess('CustomField', 'delete', ['id' => $customField1['id']]);
984 $this->callAPISuccess('CustomField', 'delete', ['id' => $customField2['id']]);
985 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $createGroup['id']]);
986 $this->callAPISuccess('CustomField', 'delete', ['id' => $multiField['id']]);
987 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $multiGroup['id']]);
991 * Test that ContactReference fields are updated to point to the main contact
992 * after a merge is performed and the duplicate contact is deleted.
994 * @throws \API_Exception
995 * @throws \CRM_Core_Exception
996 * @throws \CiviCRM_API3_Exception
997 * @throws \Civi\API\Exception\UnauthorizedException
999 public function testMigrationOfContactReferenceCustomField() {
1000 // Create Custom Fields
1001 $contactGroup = $this->setupCustomGroupForIndividual();
1002 $activityGroup = $this->customGroupCreate([
1003 'name' => 'test_group_activity',
1004 'extends' => 'Activity',
1006 $refFieldContact = $this->customFieldCreate([
1007 'custom_group_id' => $contactGroup['id'],
1008 'label' => 'field_1' . $contactGroup['id'],
1009 'data_type' => 'ContactReference',
1010 'default_value' => NULL,
1012 $refFieldActivity = $this->customFieldCreate([
1013 'custom_group_id' => $activityGroup['id'],
1014 'label' => 'field_1' . $activityGroup['id'],
1015 'data_type' => 'ContactReference',
1016 'default_value' => NULL,
1020 $this->setupMatchData();
1021 $originalContactID = $this->contacts
[0]['id'];
1022 $duplicateContactID = $this->contacts
[1]['id'];
1024 // create a contact that won't be merged but has a ContactReference field
1025 // pointing to the duplicate (to be deleted) contact
1026 $unrelatedContact = $this->individualCreate([
1027 'first_name' => 'Unrelated',
1028 'last_name' => 'Contact',
1029 'email' => 'unrelated@example.com',
1030 "custom_{$refFieldContact['id']}" => $duplicateContactID,
1032 // also create an activity with a ContactReference custom field
1033 $activity = $this->activityCreate([
1034 'target_contact_id' => $unrelatedContact,
1035 "custom_{$refFieldActivity['id']}" => $duplicateContactID,
1038 // verify that the fields were set
1039 $this->assertCustomFieldValue($unrelatedContact, $duplicateContactID, "custom_{$refFieldContact['id']}");
1040 $this->assertEntityCustomFieldValue('Activity', $activity['id'], $duplicateContactID, "custom_{$refFieldActivity['id']}_id");
1043 $this->mergeContacts($originalContactID, $duplicateContactID, []);
1045 // verify that the ContactReference fields were updated to point to the surviving contact post-merge
1046 $this->assertCustomFieldValue($unrelatedContact, $originalContactID, "custom_{$refFieldContact['id']}");
1047 $this->assertEntityCustomFieldValue('Activity', $activity['id'], $originalContactID, "custom_{$refFieldActivity['id']}_id");
1049 // cleanup created custom set
1050 $this->callAPISuccess('CustomField', 'delete', ['id' => $refFieldContact['id']]);
1051 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $contactGroup['id']]);
1052 $this->callAPISuccess('CustomField', 'delete', ['id' => $refFieldActivity['id']]);
1053 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $activityGroup['id']]);
1057 * Calls merge method on given contacts, with values given in $params array.
1059 * @param $originalContactID
1060 * ID of target contact
1061 * @param $duplicateContactID
1062 * ID of contact to be merged
1064 * Array of fields to be merged from source into target contact, of the form
1065 * ['move_<fieldName>' => <fieldValue>]
1067 * @throws \API_Exception
1068 * @throws \CRM_Core_Exception
1069 * @throws \CiviCRM_API3_Exception
1071 private function mergeContacts($originalContactID, $duplicateContactID, $params): void
{
1072 $rowsElementsAndInfo = CRM_Dedupe_Merger
::getRowsElementsAndInfo($originalContactID, $duplicateContactID);
1075 'main_details' => $rowsElementsAndInfo['main_details'],
1076 'other_details' => $rowsElementsAndInfo['other_details'],
1079 // Migrate data of duplicate contact
1080 CRM_Dedupe_Merger
::moveAllBelongings($originalContactID, $duplicateContactID, array_merge($migrationData, $params));
1084 * Checks if the expected value for the given field corresponds to what is
1085 * stored in the database for the given contact ID.
1088 * @param $expectedValue
1089 * @param $customFieldName
1091 * @throws \CRM_Core_Exception
1093 private function assertCustomFieldValue($contactID, $expectedValue, $customFieldName): void
{
1094 $this->assertEntityCustomFieldValue('Contact', $contactID, $expectedValue, $customFieldName);
1098 * Check if the custom field of the given field and entity id matches the
1103 * @param $expectedValue
1104 * @param $customFieldName
1106 * @throws \CRM_Core_Exception
1108 private function assertEntityCustomFieldValue($entity, $id, $expectedValue, $customFieldName) {
1109 $data = $this->callAPISuccess($entity, 'getsingle', [
1111 'return' => [$customFieldName],
1114 $this->assertEquals($expectedValue, $data[$customFieldName], "Custom field value was supposed to be '{$expectedValue}', '{$data[$customFieldName]}' found.");
1118 * Creates a custom group to run tests on contacts that are individuals.
1121 * Data for the created custom group record
1122 * @throws \CRM_Core_Exception
1124 private function setupCustomGroupForIndividual() {
1125 $customGroup = $this->callAPISuccess('custom_group', 'get', [
1126 'name' => 'test_group',
1129 if ($customGroup['count'] > 0) {
1130 $this->callAPISuccess('CustomGroup', 'delete', ['id' => $customGroup['id']]);
1133 $customGroup = $this->callAPISuccess('custom_group', 'create', [
1134 'title' => 'Test_Group',
1135 'name' => 'test_group',
1136 'extends' => ['Individual'],
1137 'style' => 'Inline',
1138 'is_multiple' => FALSE,
1142 return $customGroup;
1146 * Creates a custom field on the provided custom group with the given field
1149 * @param string $fieldLabel
1150 * @param array $createGroup
1153 * Data for the created custom field record
1154 * @throws \CRM_Core_Exception
1156 private function setupCustomField($fieldLabel, $createGroup) {
1157 return $this->callAPISuccess('custom_field', 'create', [
1158 'label' => $fieldLabel,
1159 'data_type' => 'Alphanumeric',
1160 'html_type' => 'Text',
1161 'custom_group_id' => $createGroup['id'],
1166 * Set up some contacts for our matching.
1168 * @throws \CiviCRM_API3_Exception
1170 public function setupMatchData(): void
{
1173 'first_name' => 'Mickey',
1174 'last_name' => 'Mouse',
1175 'email' => 'mickey@mouse.com',
1178 'first_name' => 'Mickey',
1179 'last_name' => 'Mouse',
1180 'email' => 'mickey@mouse.com',
1183 'first_name' => 'Minnie',
1184 'last_name' => 'Mouse',
1185 'email' => 'mickey@mouse.com',
1188 'first_name' => 'Minnie',
1189 'last_name' => 'Mouse',
1190 'email' => 'mickey@mouse.com',
1193 foreach ($fixtures as $fixture) {
1194 $contactID = $this->individualCreate($fixture);
1195 $this->contacts
[] = array_merge($fixture, ['id' => $contactID]);
1198 $organizationFixtures = [
1200 'organization_name' => 'Walt Disney Ltd',
1201 'email' => 'walt@disney.com',
1204 'organization_name' => 'Walt Disney Ltd',
1205 'email' => 'walt@disney.com',
1208 'organization_name' => 'Walt Disney',
1209 'email' => 'walt@disney.com',
1212 'organization_name' => 'Walt Disney',
1213 'email' => 'walter@disney.com',
1216 foreach ($organizationFixtures as $fixture) {
1217 $contactID = $this->organizationCreate($fixture);
1218 $this->contacts
[] = array_merge($fixture, ['id' => $contactID]);
1223 * Get the list of tables that refer to the CID.
1225 * This is a statically maintained (in this test list).
1227 * There is also a check against an automated list but having both seems to
1228 * add extra stability to me. They do not change often.
1230 public function getStaticCIDRefs() {
1232 'civicrm_acl_cache' => [
1235 'civicrm_acl_contact_cache' => [
1238 'civicrm_action_log' => [
1241 'civicrm_activity_contact' => [
1244 'civicrm_address' => [
1247 'civicrm_batch' => [
1251 'civicrm_campaign' => [
1253 1 => 'last_modified_id',
1255 'civicrm_case_contact' => [
1258 'civicrm_contact' => [
1259 0 => 'primary_contact_id',
1262 'civicrm_contribution' => [
1265 'civicrm_contribution_page' => [
1268 'civicrm_contribution_recur' => [
1271 'civicrm_contribution_soft' => [
1274 'civicrm_custom_group' => [
1277 'civicrm_dashboard_contact' => [
1280 'civicrm_dedupe_exception' => [
1284 'civicrm_domain' => [
1287 'civicrm_email' => [
1290 'civicrm_event' => [
1293 'civicrm_event_carts' => [
1296 'civicrm_financial_account' => [
1299 'civicrm_financial_item' => [
1302 'civicrm_grant' => [
1305 'civicrm_group' => [
1309 'civicrm_group_contact' => [
1312 'civicrm_group_contact_cache' => [
1315 'civicrm_group_organization' => [
1316 0 => 'organization_id',
1324 'civicrm_mailing' => [
1326 1 => 'scheduled_id',
1332 'civicrm_mailing_abtest' => [
1335 'civicrm_mailing_event_queue' => [
1338 'civicrm_mailing_event_subscribe' => [
1341 'civicrm_mailing_recipients' => [
1344 'civicrm_membership' => [
1347 'civicrm_membership_log' => [
1350 'civicrm_membership_type' => [
1351 0 => 'member_of_contact_id',
1356 'civicrm_openid' => [
1359 'civicrm_participant' => [
1362 1 => 'transferred_to_contact_id',
1364 'civicrm_payment_token' => [
1371 'civicrm_phone' => [
1374 'civicrm_pledge' => [
1377 'civicrm_print_label' => [
1380 'civicrm_saved_search' => ['created_id', 'modified_id'],
1381 'civicrm_relationship' => [
1382 0 => 'contact_id_a',
1383 1 => 'contact_id_b',
1385 'civicrm_relationship_cache' => [
1386 0 => 'near_contact_id',
1387 1 => 'far_contact_id',
1389 'civicrm_report_instance' => [
1393 'civicrm_setting' => [
1397 'civicrm_subscription_history' => [
1400 'civicrm_survey' => [
1402 1 => 'last_modified_id',
1407 'civicrm_uf_group' => [
1410 'civicrm_uf_match' => [
1413 'civicrm_value_testgetcidref_1' => [
1416 'civicrm_website' => [
1423 * Get a list of CIDs that is calculated off the schema.
1425 * Note this is an expensive and table locking query. Should be safe in tests
1428 public function getCalculatedCIDRefs() {
1432 table_name AS table_name,
1433 column_name AS column_name
1434 FROM information_schema.key_column_usage
1436 referenced_table_schema = database() AND
1437 referenced_table_name = 'civicrm_contact' AND
1438 referenced_column_name = 'id';
1440 $dao = CRM_Core_DAO
::executeQuery($sql);
1441 while ($dao->fetch()) {
1442 $cidRefs[$dao->table_name
][] = $dao->column_name
;
1444 // Do specific re-ordering changes to make this the same as the ref validated one.
1445 // The above query orders by FK alphabetically.
1446 // There might be cleverer ways to do this but it shouldn't change much.
1447 $cidRefs['civicrm_contact'][0] = 'primary_contact_id';
1448 $cidRefs['civicrm_contact'][1] = 'employer_id';
1449 $cidRefs['civicrm_acl_contact_cache'][0] = 'contact_id';
1450 $cidRefs['civicrm_mailing'][0] = 'created_id';
1451 $cidRefs['civicrm_mailing'][1] = 'scheduled_id';
1452 $cidRefs['civicrm_mailing'][2] = 'approver_id';