Merge pull request #17799 from civicrm/5.28
[civicrm-core.git] / tests / phpunit / api / v3 / JobTest.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 * File for the CiviCRM APIv3 job functions
14 *
15 * @package CiviCRM_APIv3
16 * @subpackage API_Job
17 *
18 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 */
20
21 /**
22 * Class api_v3_JobTest
23 *
24 * @group headless
25 */
26 class api_v3_JobTest extends CiviUnitTestCase {
27
28 public $DBResetRequired = FALSE;
29
30 public $_entity = 'Job';
31
32 /**
33 * Created membership type.
34 *
35 * Must be created outside the transaction due to it breaking the transaction.
36 *
37 * @var int
38 */
39 public $membershipTypeID;
40
41 /**
42 * Set up for tests.
43 */
44 public function setUp() {
45 parent::setUp();
46 $this->membershipTypeID = $this->membershipTypeCreate(['name' => 'General']);
47 $this->useTransaction(TRUE);
48 $this->_params = [
49 'sequential' => 1,
50 'name' => 'API_Test_Job',
51 'description' => 'A long description written by hand in cursive',
52 'run_frequency' => 'Daily',
53 'api_entity' => 'ApiTestEntity',
54 'api_action' => 'apitestaction',
55 'parameters' => 'Semi-formal explanation of runtime job parameters',
56 'is_active' => 1,
57 ];
58 }
59
60 /**
61 * Cleanup after test.
62 *
63 * @throws \CRM_Core_Exception
64 */
65 public function tearDown() {
66 parent::tearDown();
67 // The membershipType create breaks transactions so this extra cleanup is needed.
68 $this->membershipTypeDelete(['id' => $this->membershipTypeID]);
69 $this->cleanUpSetUpIDs();
70 $this->quickCleanUpFinancialEntities();
71 $this->quickCleanup(['civicrm_contact', 'civicrm_address', 'civicrm_email', 'civicrm_website', 'civicrm_phone'], TRUE);
72 parent::tearDown();
73 }
74
75 /**
76 * Check with no name.
77 */
78 public function testCreateWithoutName() {
79 $params = [
80 'is_active' => 1,
81 ];
82 $this->callAPIFailure('job', 'create', $params,
83 'Mandatory key(s) missing from params array: run_frequency, name, api_entity, api_action'
84 );
85 }
86
87 /**
88 * Create job with an invalid "run_frequency" value.
89 */
90 public function testCreateWithInvalidFrequency() {
91 $params = [
92 'sequential' => 1,
93 'name' => 'API_Test_Job',
94 'description' => 'A long description written by hand in cursive',
95 'run_frequency' => 'Fortnightly',
96 'api_entity' => 'ApiTestEntity',
97 'api_action' => 'apitestaction',
98 'parameters' => 'Semi-formal explanation of runtime job parameters',
99 'is_active' => 1,
100 ];
101 $this->callAPIFailure('job', 'create', $params);
102 }
103
104 /**
105 * Create job.
106 */
107 public function testCreate() {
108 $result = $this->callAPIAndDocument('job', 'create', $this->_params, __FUNCTION__, __FILE__);
109 $this->assertNotNull($result['values'][0]['id']);
110
111 // mutate $params to match expected return value
112 unset($this->_params['sequential']);
113 //assertDBState compares expected values in $result to actual values in the DB
114 $this->assertDBState('CRM_Core_DAO_Job', $result['id'], $this->_params);
115 }
116
117 /**
118 * Clone job
119 *
120 * @throws \CRM_Core_Exception
121 */
122 public function testClone() {
123 $createResult = $this->callAPISuccess('job', 'create', $this->_params);
124 $params = ['id' => $createResult['id']];
125 $cloneResult = $this->callAPIAndDocument('job', 'clone', $params, __FUNCTION__, __FILE__);
126 $clonedJob = $cloneResult['values'][$cloneResult['id']];
127 $this->assertEquals($this->_params['name'] . ' - Copy', $clonedJob['name']);
128 $this->assertEquals($this->_params['description'], $clonedJob['description']);
129 $this->assertEquals($this->_params['parameters'], $clonedJob['parameters']);
130 $this->assertEquals($this->_params['is_active'], $clonedJob['is_active']);
131 $this->assertArrayNotHasKey('last_run', $clonedJob);
132 $this->assertArrayNotHasKey('scheduled_run_date', $clonedJob);
133 }
134
135 /**
136 * Check if required fields are not passed.
137 */
138 public function testDeleteWithoutRequired() {
139 $params = [
140 'name' => 'API_Test_PP',
141 'title' => 'API Test Payment Processor',
142 'class_name' => 'CRM_Core_Payment_APITest',
143 ];
144
145 $result = $this->callAPIFailure('job', 'delete', $params);
146 $this->assertEquals($result['error_message'], 'Mandatory key(s) missing from params array: id');
147 }
148
149 /**
150 * Check with incorrect required fields.
151 */
152 public function testDeleteWithIncorrectData() {
153 $params = [
154 'id' => 'abcd',
155 ];
156 $this->callAPIFailure('job', 'delete', $params);
157 }
158
159 /**
160 * Check job delete.
161 */
162 public function testDelete() {
163 $createResult = $this->callAPISuccess('job', 'create', $this->_params);
164 $params = ['id' => $createResult['id']];
165 $this->callAPIAndDocument('job', 'delete', $params, __FUNCTION__, __FILE__);
166 $this->assertAPIDeleted($this->_entity, $createResult['id']);
167 }
168
169 /**
170 * Test greeting update job.
171 *
172 * Note that this test is about tesing the metadata / calling of the function & doesn't test the success of the called function
173 *
174 * @throws \CRM_Core_Exception
175 */
176 public function testCallUpdateGreetingSuccess() {
177 $this->callAPISuccess($this->_entity, 'update_greeting', [
178 'gt' => 'postal_greeting',
179 'ct' => 'Individual',
180 ]);
181 }
182
183 /**
184 * Test greeting update handles comma separated params.
185 *
186 * @throws \CRM_Core_Exception
187 */
188 public function testCallUpdateGreetingCommaSeparatedParamsSuccess() {
189 $gt = 'postal_greeting,email_greeting,addressee';
190 $ct = 'Individual,Household';
191 $this->callAPISuccess($this->_entity, 'update_greeting', ['gt' => $gt, 'ct' => $ct]);
192 }
193
194 /**
195 * Test the call reminder success sends more than 25 reminders & is not incorrectly limited.
196 *
197 * Note that this particular test sends the reminders to the additional recipients only
198 * as no real reminder person is configured
199 *
200 * Also note that this is testing a 'job' api so is in this class rather than scheduled_reminder - which
201 * seems a cleaner place to build up a collection of scheduled reminder testing functions. However, it seems
202 * that the api itself would need to be moved to the scheduled_reminder fn to do that with the job wrapper being respected for legacy functions
203 *
204 * @throws \CRM_Core_Exception
205 */
206 public function testCallSendReminderSuccessMoreThanDefaultLimit() {
207 $membershipTypeID = $this->membershipTypeCreate();
208 $this->membershipStatusCreate();
209 $createTotal = 30;
210 for ($i = 1; $i <= $createTotal; $i++) {
211 $contactID = $this->individualCreate();
212 $groupID = $this->groupCreate(['name' => $i, 'title' => $i]);
213 $this->callAPISuccess('action_schedule', 'create', [
214 'title' => " job $i",
215 'subject' => "job $i",
216 'entity_value' => $membershipTypeID,
217 'mapping_id' => 4,
218 'start_action_date' => 'membership_join_date',
219 'start_action_offset' => 0,
220 'start_action_condition' => 'before',
221 'start_action_unit' => 'hour',
222 'group_id' => $groupID,
223 'limit_to' => FALSE,
224 ]);
225 $this->callAPISuccess('group_contact', 'create', [
226 'contact_id' => $contactID,
227 'status' => 'Added',
228 'group_id' => $groupID,
229 ]);
230 }
231 $this->callAPISuccess('job', 'send_reminder', []);
232 $successfulCronCount = CRM_Core_DAO::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
233 $this->assertEquals($successfulCronCount, $createTotal);
234 }
235
236 /**
237 * Test scheduled reminders respect limit to (since above identified addition_to handling issue).
238 *
239 * We create 3 contacts - 1 is in our group, 1 has our membership & the chosen one has both
240 * & check that only the chosen one got the reminder
241 *
242 * @throws \CRM_Core_Exception
243 * @throws \CiviCRM_API3_Exception
244 */
245 public function testCallSendReminderLimitToSMS() {
246 list($membershipTypeID, $groupID, $theChosenOneID, $provider) = $this->setUpMembershipSMSReminders();
247 $this->callAPISuccess('action_schedule', 'create', [
248 'title' => ' remind all Texans',
249 'subject' => 'drawling renewal',
250 'entity_value' => $membershipTypeID,
251 'mapping_id' => 4,
252 'start_action_date' => 'membership_start_date',
253 'start_action_offset' => 1,
254 'start_action_condition' => 'before',
255 'start_action_unit' => 'day',
256 'group_id' => $groupID,
257 'limit_to' => TRUE,
258 'sms_provider_id' => $provider['id'],
259 'mode' => 'User_Preference',
260 ]);
261 $this->callAPISuccess('job', 'send_reminder', []);
262 $successfulCronCount = CRM_Core_DAO::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
263 $this->assertEquals($successfulCronCount, 1);
264 $sentToID = CRM_Core_DAO::singleValueQuery("SELECT contact_id FROM civicrm_action_log");
265 $this->assertEquals($sentToID, $theChosenOneID);
266 $this->assertEquals(0, CRM_Core_DAO::singleValueQuery("SELECT is_error FROM civicrm_action_log"));
267 $this->setupForSmsTests(TRUE);
268 }
269
270 /**
271 * Test disabling expired relationships.
272 *
273 * @throws \CRM_Core_Exception
274 */
275 public function testCallDisableExpiredRelationships() {
276 $individualID = $this->individualCreate();
277 $orgID = $this->organizationCreate();
278 CRM_Utils_Hook_UnitTests::singleton()->setHook('civicrm_pre', [$this, 'hookPreRelationship']);
279 $relationshipTypeID = $this->callAPISuccess('relationship_type', 'getvalue', [
280 'return' => 'id',
281 'name_a_b' => 'Employee of',
282 ]);
283 $result = $this->callAPISuccess('relationship', 'create', [
284 'relationship_type_id' => $relationshipTypeID,
285 'contact_id_a' => $individualID,
286 'contact_id_b' => $orgID,
287 'is_active' => 1,
288 'end_date' => 'yesterday',
289 ]);
290 $relationshipID = $result['id'];
291 $this->assertEquals('Hooked', $result['values'][$relationshipID]['description']);
292 $this->callAPISuccess($this->_entity, 'disable_expired_relationships', []);
293 $result = $this->callAPISuccess('relationship', 'get', []);
294 $this->assertEquals('Go Go you good thing', $result['values'][$relationshipID]['description']);
295 $this->contactDelete($individualID);
296 $this->contactDelete($orgID);
297 }
298
299 /**
300 * Event templates should not send reminders to additional contacts.
301 *
302 * @throws \CRM_Core_Exception
303 */
304 public function testTemplateRemindAddlContacts() {
305 $contactId = $this->individualCreate();
306 $groupId = $this->groupCreate(['name' => 'Additional Contacts', 'title' => 'Additional Contacts']);
307 $this->callAPISuccess('GroupContact', 'create', [
308 'contact_id' => $contactId,
309 'group_id' => $groupId,
310 ]);
311 $event = $this->eventCreate(['is_template' => 1, 'template_title' => "I'm a template", 'title' => NULL]);
312 $eventId = $event['id'];
313
314 $this->callAPISuccess('action_schedule', 'create', [
315 'title' => 'Do not send me',
316 'subject' => 'I am a reminder attached to a template.',
317 'entity_value' => $eventId,
318 'mapping_id' => 5,
319 'start_action_date' => 'start_date',
320 'start_action_offset' => 1,
321 'start_action_condition' => 'before',
322 'start_action_unit' => 'day',
323 'group_id' => $groupId,
324 'limit_to' => FALSE,
325 'mode' => 'Email',
326 ]);
327
328 $this->callAPISuccess('job', 'send_reminder', []);
329 $successfulCronCount = CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_action_log');
330 $this->assertEquals(0, $successfulCronCount);
331 }
332
333 /**
334 * Test scheduled reminders respect limit to (since above identified addition_to handling issue).
335 *
336 * We create 3 contacts - 1 is in our group, 1 has our membership & the chosen one has both
337 * & check that only the chosen one got the reminder
338 *
339 * Also check no hard fail on cron job with running a reminder that has a deleted SMS provider
340 *
341 * @throws \CRM_Core_Exception
342 * @throws \CiviCRM_API3_Exception
343 */
344 public function testCallSendReminderLimitToSMSWithDeletedProvider() {
345 list($membershipTypeID, $groupID, $theChosenOneID, $provider) = $this->setUpMembershipSMSReminders();
346 $this->callAPISuccess('action_schedule', 'create', [
347 'title' => ' remind all Texans',
348 'subject' => 'drawling renewal',
349 'entity_value' => $membershipTypeID,
350 'mapping_id' => 4,
351 'start_action_date' => 'membership_start_date',
352 'start_action_offset' => 1,
353 'start_action_condition' => 'before',
354 'start_action_unit' => 'day',
355 'group_id' => $groupID,
356 'limit_to' => TRUE,
357 'sms_provider_id' => $provider['id'],
358 'mode' => 'SMS',
359 ]);
360 $this->callAPISuccess('SmsProvider', 'delete', ['id' => $provider['id']]);
361 $this->callAPISuccess('job', 'send_reminder', []);
362 $cronCount = CRM_Core_DAO::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
363 $this->assertEquals($cronCount, 1);
364 $sentToID = CRM_Core_DAO::singleValueQuery("SELECT contact_id FROM civicrm_action_log");
365 $this->assertEquals($sentToID, $theChosenOneID);
366 $cronlog = CRM_Core_DAO::executeQuery("SELECT * FROM civicrm_action_log")->fetchAll()[0];
367 $this->assertEquals(1, $cronlog['is_error']);
368 $this->assertEquals('SMS reminder cannot be sent because the SMS provider has been deleted.', $cronlog['message']);
369 $this->setupForSmsTests(TRUE);
370 }
371
372 /**
373 * Test the batch merge function.
374 *
375 * We are just checking it returns without error here.
376 *
377 * @throws \CRM_Core_Exception
378 */
379 public function testBatchMerge() {
380 $this->callAPISuccess('Job', 'process_batch_merge', []);
381 }
382
383 /**
384 * Test the batch merge function actually works!
385 *
386 * @dataProvider getMergeSets
387 *
388 * @param $dataSet
389 *
390 * @throws \CRM_Core_Exception
391 */
392 public function testBatchMergeWorks($dataSet) {
393 foreach ($dataSet['contacts'] as $params) {
394 $this->callAPISuccess('Contact', 'create', $params);
395 }
396
397 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => $dataSet['mode']]);
398 $this->assertCount($dataSet['skipped'], $result['values']['skipped'], 'Failed to skip the right number:' . $dataSet['skipped']);
399 $this->assertCount($dataSet['merged'], $result['values']['merged']);
400 $result = $this->callAPISuccess('Contact', 'get', [
401 'contact_sub_type' => 'Student',
402 'sequential' => 1,
403 'is_deceased' => ['IN' => [0, 1]],
404 'options' => ['sort' => 'id ASC'],
405 ]);
406 $this->assertEquals(count($dataSet['expected']), $result['count']);
407 foreach ($dataSet['expected'] as $index => $contact) {
408 foreach ($contact as $key => $value) {
409 if ($key === 'gender_id') {
410 $key = 'gender';
411 }
412 $this->assertEquals($value, $result['values'][$index][$key]);
413 }
414 }
415 }
416
417 /**
418 * Check that the merge carries across various related entities.
419 *
420 * Note the group combinations & expected results:
421 *
422 * @throws \CRM_Core_Exception
423 */
424 public function testBatchMergeWithAssets() {
425 $contactID = $this->individualCreate();
426 $contact2ID = $this->individualCreate();
427 $this->contributionCreate(['contact_id' => $contactID]);
428 $this->contributionCreate(['contact_id' => $contact2ID, 'invoice_id' => '2', 'trxn_id' => 2]);
429 $this->contactMembershipCreate(['contact_id' => $contactID]);
430 $this->contactMembershipCreate(['contact_id' => $contact2ID]);
431 $this->activityCreate(['source_contact_id' => $contactID, 'target_contact_id' => $contactID, 'assignee_contact_id' => $contactID]);
432 $this->activityCreate(['source_contact_id' => $contact2ID, 'target_contact_id' => $contact2ID, 'assignee_contact_id' => $contact2ID]);
433 $this->tagCreate(['name' => 'Tall']);
434 $this->tagCreate(['name' => 'Short']);
435 $this->entityTagAdd(['contact_id' => $contactID, 'tag_id' => 'Tall']);
436 $this->entityTagAdd(['contact_id' => $contact2ID, 'tag_id' => 'Short']);
437 $this->entityTagAdd(['contact_id' => $contact2ID, 'tag_id' => 'Tall']);
438 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
439 $this->assertCount(0, $result['values']['skipped']);
440 $this->assertCount(1, $result['values']['merged']);
441 $this->callAPISuccessGetCount('Contribution', ['contact_id' => $contactID], 2);
442 $this->callAPISuccessGetCount('Contribution', ['contact_id' => $contact2ID], 0);
443 $this->callAPISuccessGetCount('FinancialItem', ['contact_id' => $contactID], 2);
444 $this->callAPISuccessGetCount('FinancialItem', ['contact_id' => $contact2ID], 0);
445 $this->callAPISuccessGetCount('Membership', ['contact_id' => $contactID], 2);
446 $this->callAPISuccessGetCount('Membership', ['contact_id' => $contact2ID], 0);
447 $this->callAPISuccessGetCount('EntityTag', ['contact_id' => $contactID], 2);
448 $this->callAPISuccessGetCount('EntityTag', ['contact_id' => $contact2ID], 0);
449 // 14 activities is one for each contribution (2), two (source + target) for each membership (+(2x2) = 6)
450 // 3 for each of the added activities as there are 3 roles (+6 = 12
451 // 2 for the (source & target) contact merged activity (+2 = 14)
452 $this->callAPISuccessGetCount('ActivityContact', ['contact_id' => $contactID], 14);
453 // 2 for the connection to the deleted by merge activity (source & target)
454 $this->callAPISuccessGetCount('ActivityContact', ['contact_id' => $contact2ID], 2);
455 }
456
457 /**
458 * Test that non-contact entity tags are untouched in merge.
459 *
460 * @throws \CRM_Core_Exception
461 */
462 public function testContributionEntityTag() {
463 $this->callAPISuccess('OptionValue', 'create', ['option_group_id' => 'tag_used_for', 'value' => 'civicrm_contribution', 'label' => 'Contribution']);
464 $tagID = $this->tagCreate(['name' => 'Big', 'used_for' => 'civicrm_contribution'])['id'];
465 $contact1 = (int) $this->individualCreate();
466 $contact2 = (int) $this->individualCreate();
467 $contributionID = NULL;
468 while ($contributionID !== $contact2) {
469 $contributionID = (int) $this->callAPISuccess('Contribution', 'create', ['contact_id' => $contact1, 'total_amount' => 5, 'financial_type_id' => 'Donation'])['id'];
470 }
471 $entityTagParams = ['entity_id' => $contributionID, 'entity_table' => 'civicrm_contribution', 'tag_id' => $tagID];
472 $this->callAPISuccess('EntityTag', 'create', $entityTagParams);
473 $this->callAPISuccessGetSingle('EntityTag', $entityTagParams);
474 $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
475 $this->callAPISuccessGetSingle('EntityTag', $entityTagParams);
476 }
477
478 /**
479 * Check that the merge carries across various related entities.
480 *
481 * Note the group combinations 'expected' results:
482 *
483 * Group 0 Added null Added
484 * Group 1 Added Added Added
485 * Group 2 Added Removed **** Added
486 * Group 3 Removed null **** null
487 * Group 4 Removed Added **** Added
488 * Group 5 Removed Removed **** null
489 * Group 6 null Added Added
490 * Group 7 null Removed **** null
491 *
492 * The ones with **** are the ones where I think a case could be made to change the behaviour.
493 *
494 * @throws \CRM_Core_Exception
495 */
496 public function testBatchMergeMergesGroups() {
497 $contactID = $this->individualCreate();
498 $contact2ID = $this->individualCreate();
499 $groups = [];
500 for ($i = 0; $i < 8; $i++) {
501 $groups[] = $this->groupCreate([
502 'name' => 'mergeGroup' . $i,
503 'title' => 'merge group' . $i,
504 ]);
505 }
506
507 $this->callAPISuccess('GroupContact', 'create', [
508 'contact_id' => $contactID,
509 'group_id' => $groups[0],
510 ]);
511 $this->callAPISuccess('GroupContact', 'create', [
512 'contact_id' => $contactID,
513 'group_id' => $groups[1],
514 ]);
515 $this->callAPISuccess('GroupContact', 'create', [
516 'contact_id' => $contactID,
517 'group_id' => $groups[2],
518 ]);
519 $this->callAPISuccess('GroupContact', 'create', [
520 'contact_id' => $contactID,
521 'group_id' => $groups[3],
522 'status' => 'Removed',
523 ]);
524 $this->callAPISuccess('GroupContact', 'create', [
525 'contact_id' => $contactID,
526 'group_id' => $groups[4],
527 'status' => 'Removed',
528 ]);
529 $this->callAPISuccess('GroupContact', 'create', [
530 'contact_id' => $contactID,
531 'group_id' => $groups[5],
532 'status' => 'Removed',
533 ]);
534 $this->callAPISuccess('GroupContact', 'create', [
535 'contact_id' => $contact2ID,
536 'group_id' => $groups[1],
537 ]);
538 $this->callAPISuccess('GroupContact', 'create', [
539 'contact_id' => $contact2ID,
540 'group_id' => $groups[2],
541 'status' => 'Removed',
542 ]);
543 $this->callAPISuccess('GroupContact', 'create', [
544 'contact_id' => $contact2ID,
545 'group_id' => $groups[4],
546 ]);
547 $this->callAPISuccess('GroupContact', 'create', [
548 'contact_id' => $contact2ID,
549 'group_id' => $groups[5],
550 'status' => 'Removed',
551 ]);
552 $this->callAPISuccess('GroupContact', 'create', [
553 'contact_id' => $contact2ID,
554 'group_id' => $groups[6],
555 ]);
556 $this->callAPISuccess('GroupContact', 'create', [
557 'contact_id' => $contact2ID,
558 'group_id' => $groups[7],
559 'status' => 'Removed',
560 ]);
561 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
562 $this->assertCount(0, $result['values']['skipped']);
563 $this->assertCount(1, $result['values']['merged']);
564 $groupResult = $this->callAPISuccess('GroupContact', 'get', []);
565 $this->assertEquals(5, $groupResult['count']);
566 $expectedGroups = [
567 $groups[0],
568 $groups[1],
569 $groups[2],
570 $groups[4],
571 $groups[6],
572 ];
573 foreach ($groupResult['values'] as $groupValues) {
574 $this->assertEquals($contactID, $groupValues['contact_id']);
575 $this->assertEquals('Added', $groupValues['status']);
576 $this->assertContains($groupValues['group_id'], $expectedGroups);
577
578 }
579 }
580
581 /**
582 * Test that we handle cache entries without clashes.
583 */
584 public function testMergeCaches() {
585 $contactID = $this->individualCreate();
586 $contact2ID = $this->individualCreate();
587 $groupID = $this->groupCreate();
588 $this->callAPISuccess('GroupContact', 'create', ['group_id' => $groupID, 'contact_id' => $contactID]);
589 $this->callAPISuccess('GroupContact', 'create', ['group_id' => $groupID, 'contact_id' => $contact2ID]);
590 CRM_Core_DAO::executeQuery("INSERT INTO civicrm_group_contact_cache(group_id, contact_id) VALUES
591 ($groupID, $contactID),
592 ($groupID, $contact2ID)
593 ");
594 $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
595 }
596
597 /**
598 * Test that we handle cache entries without clashes.
599 */
600 public function testMergeSharedActivity() {
601 $contactID = $this->individualCreate();
602 $contact2ID = $this->individualCreate();
603 $activityID = $this->activityCreate(['target_contact_id' => [$contactID, $contact2ID]]);
604 $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
605 }
606
607 /**
608 * Test the decisions made for addresses when merging.
609 *
610 * @dataProvider getMergeLocationData
611 *
612 * Scenarios:
613 * (the ones with **** could be disputed as whether it is the best outcome).
614 * 'matching_primary' - Primary matches, including location_type_id. One contact has an additional address.
615 * - result - primary is the shared one. Additional address is retained.
616 * 'matching_primary_reverse' - Primary matches, including location_type_id. Keep both. (opposite order)
617 * - result - primary is the shared one. Additional address is retained.
618 * 'only_one_has_address' - Only one contact has addresses (retain)
619 * - the (only) address is retained
620 * 'only_one_has_address_reverse'
621 * - the (only) address is retained
622 * 'different_primaries_with_different_location_type' Primaries are different but do not clash due to diff type
623 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
624 * 'different_primaries_with_different_location_type_reverse' Primaries are different but do not clash due to diff type
625 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
626 * 'different_primaries_location_match_only_one_address' per previous but a second address matches the primary but is not primary
627 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
628 * 'different_primaries_location_match_only_one_address_reverse' per previous but a second address matches the primary but is not primary
629 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
630 * 'same_primaries_different_location' Primary addresses are the same but have different location type IDs
631 * - result primary kept with the lowest ID. Other address retained too (to preserve location type info).
632 * 'same_primaries_different_location_reverse' Primary addresses are the same but have different location type IDs
633 * - result primary kept with the lowest ID. Other address retained too (to preserve location type info).
634 *
635 * @param array $dataSet
636 *
637 * @throws \CRM_Core_Exception
638 */
639 public function testBatchMergesAddresses($dataSet) {
640 $contactID1 = $this->individualCreate();
641 $contactID2 = $this->individualCreate();
642 foreach ($dataSet['contact_1'] as $address) {
643 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID1], $address));
644 }
645 foreach ($dataSet['contact_2'] as $address) {
646 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID2], $address));
647 }
648
649 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
650 $this->assertCount(1, $result['values']['merged']);
651 $addresses = $this->callAPISuccess($dataSet['entity'], 'get', ['contact_id' => $contactID1, 'sequential' => 1]);
652 $this->assertEquals(count($dataSet['expected']), $addresses['count'], 'Did not get the expected result for ' . $dataSet['entity'] . (!empty($dataSet['description']) ? " on dataset {$dataSet['description']}" : ''));
653 $locationTypes = $this->callAPISuccess($dataSet['entity'], 'getoptions', ['field' => 'location_type_id']);
654 foreach ($dataSet['expected'] as $index => $expectedAddress) {
655 foreach ($expectedAddress as $key => $value) {
656 if ($key === 'location_type_id') {
657 $this->assertEquals($locationTypes['values'][$addresses['values'][$index][$key]], $value);
658 }
659 else {
660 $this->assertEquals($addresses['values'][$index][$key], $value, "mismatch on $key" . (!empty($dataSet['description']) ? " on dataset {$dataSet['description']}" : ''));
661 }
662 }
663 }
664 }
665
666 /**
667 * Test altering the address decision by hook.
668 *
669 * @dataProvider getMergeLocationData
670 *
671 * @param array $dataSet
672 *
673 * @throws \CRM_Core_Exception
674 */
675 public function testBatchMergesAddressesHook($dataSet) {
676 $contactID1 = $this->individualCreate();
677 $contactID2 = $this->individualCreate();
678 $this->contributionCreate(['contact_id' => $contactID1, 'receive_date' => '2010-01-01', 'invoice_id' => 1, 'trxn_id' => 1]);
679 $this->contributionCreate(['contact_id' => $contactID2, 'receive_date' => '2012-01-01', 'invoice_id' => 2, 'trxn_id' => 2]);
680 foreach ($dataSet['contact_1'] as $address) {
681 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID1], $address));
682 }
683 foreach ($dataSet['contact_2'] as $address) {
684 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID2], $address));
685 }
686 $this->hookClass->setHook('civicrm_alterLocationMergeData', [$this, 'hookMostRecentDonor']);
687
688 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
689 $this->assertCount(1, $result['values']['merged']);
690 $addresses = $this->callAPISuccess($dataSet['entity'], 'get', ['contact_id' => $contactID1, 'sequential' => 1]);
691 $this->assertEquals(count($dataSet['expected_hook']), $addresses['count']);
692 $locationTypes = $this->callAPISuccess($dataSet['entity'], 'getoptions', ['field' => 'location_type_id']);
693 foreach ($dataSet['expected_hook'] as $index => $expectedAddress) {
694 foreach ($expectedAddress as $key => $value) {
695 if ($key === 'location_type_id') {
696 $this->assertEquals($locationTypes['values'][$addresses['values'][$index][$key]], $value);
697 }
698 else {
699 $this->assertEquals($value, $addresses['values'][$index][$key], $dataSet['entity'] . ': Unexpected value for ' . $key . (!empty($dataSet['description']) ? " on dataset {$dataSet['description']}" : ''));
700 }
701 }
702 }
703 }
704
705 /**
706 * Test the organization will not be matched to an individual.
707 *
708 * @throws \CRM_Core_Exception
709 */
710 public function testBatchMergeWillNotMergeOrganizationToIndividual() {
711 $individual = $this->callAPISuccess('Contact', 'create', [
712 'contact_type' => 'Individual',
713 'organization_name' => 'Anon',
714 'email' => 'anonymous@hacker.com',
715 ]);
716 $organization = $this->callAPISuccess('Contact', 'create', [
717 'contact_type' => 'Organization',
718 'organization_name' => 'Anon',
719 'email' => 'anonymous@hacker.com',
720 ]);
721 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'aggressive']);
722 $this->assertCount(0, $result['values']['skipped']);
723 $this->assertCount(0, $result['values']['merged']);
724 $this->callAPISuccessGetSingle('Contact', ['id' => $individual['id']]);
725 $this->callAPISuccessGetSingle('Contact', ['id' => $organization['id']]);
726
727 }
728
729 /**
730 * Test hook allowing modification of the data calculated for merging locations.
731 *
732 * We are testing a nuanced real life situation where the address data of the
733 * most recent donor gets priority - resulting in the primary address being set
734 * to the primary address of the most recent donor and address data on a per
735 * location type basis also being set to the most recent donor. Hook also excludes
736 * a fully matching address with a different location.
737 *
738 * This has been added to the test suite to ensure the code supports more this
739 * type of intervention.
740 *
741 * @param array $blocksDAO
742 * Array of location DAO to be saved. These are arrays in 2 keys 'update' & 'delete'.
743 * @param int $mainId
744 * Contact_id of the contact that survives the merge.
745 * @param int $otherId
746 * Contact_id of the contact that will be absorbed and deleted.
747 * @param array $migrationInfo
748 * Calculated migration info, informational only.
749 *
750 * @return mixed
751 * @throws \CRM_Core_Exception
752 */
753 public function hookMostRecentDonor(&$blocksDAO, $mainId, $otherId, $migrationInfo) {
754
755 $lastDonorID = $this->callAPISuccessGetValue('Contribution', [
756 'return' => 'contact_id',
757 'contact_id' => ['IN' => [$mainId, $otherId]],
758 'options' => ['sort' => 'receive_date DESC', 'limit' => 1],
759 ]);
760 // Since the last donor is not the main ID we are prioritising info from the last donor.
761 // In the test this should always be true - but keep the check in case
762 // something changes that we need to detect.
763 if ($lastDonorID != $mainId) {
764 foreach ($migrationInfo['other_details']['location_blocks'] as $blockType => $blocks) {
765 foreach ($blocks as $block) {
766 if ($block['is_primary']) {
767 $primaryAddressID = $block['id'];
768 if (!empty($migrationInfo['main_details']['location_blocks'][$blockType])) {
769 foreach ($migrationInfo['main_details']['location_blocks'][$blockType] as $mainBlock) {
770 if (empty($blocksDAO[$blockType]['update'][$block['id']]) && $mainBlock['location_type_id'] == $block['location_type_id']) {
771 // This was an address match - we just need to check the is_primary
772 // is true on the matching kept address.
773 $primaryAddressID = $mainBlock['id'];
774 $blocksDAO[$blockType]['update'][$primaryAddressID] = _civicrm_api3_load_DAO($blockType);
775 $blocksDAO[$blockType]['update'][$primaryAddressID]->id = $primaryAddressID;
776 }
777 $mainLocationTypeID = $mainBlock['location_type_id'];
778 // We also want to be more ruthless about removing matching addresses.
779 unset($mainBlock['location_type_id']);
780 if (CRM_Dedupe_Merger::locationIsSame($block, $mainBlock)
781 && (!isset($blocksDAO[$blockType]['update']) || !isset($blocksDAO[$blockType]['update'][$mainBlock['id']]))
782 && (!isset($blocksDAO[$blockType]['delete']) || !isset($blocksDAO[$blockType]['delete'][$mainBlock['id']]))
783 ) {
784 $blocksDAO[$blockType]['delete'][$mainBlock['id']] = _civicrm_api3_load_DAO($blockType);
785 $blocksDAO[$blockType]['delete'][$mainBlock['id']]->id = $mainBlock['id'];
786 }
787 // Arguably the right way to handle this is just to set is_primary for the primary
788 // and for the merge fn to call something like BAO::add & hooks to work etc.
789 // if that happens though this should keep working...
790 elseif ($mainBlock['is_primary'] && $mainLocationTypeID != $block['location_type_id']) {
791 $blocksDAO['address']['update'][$mainBlock['id']] = _civicrm_api3_load_DAO($blockType);
792 $blocksDAO['address']['update'][$mainBlock['id']]->is_primary = 0;
793 $blocksDAO['address']['update'][$mainBlock['id']]->id = $mainBlock['id'];
794 }
795
796 }
797 $blocksDAO[$blockType]['update'][$primaryAddressID]->is_primary = 1;
798 }
799 }
800 }
801 }
802 }
803 }
804
805 /**
806 * Get address combinations for the merge test.
807 *
808 * @return array
809 */
810 public function getMergeLocationData() {
811 $address1 = ['street_address' => 'Buckingham Palace', 'city' => 'London'];
812 $address2 = ['street_address' => 'The Doghouse', 'supplemental_address_1' => 'under the blanket'];
813 $data = $this->getMergeLocations($address1, $address2, 'Address');
814 $data = array_merge($data, $this->getMergeLocations(['phone' => '12345', 'phone_type_id' => 1], ['phone' => '678910', 'phone_type_id' => 1], 'Phone'));
815 $data = array_merge($data, $this->getMergeLocations(['phone' => '12345'], ['phone' => '678910'], 'Phone'));
816 $data = array_merge($data, $this->getMergeLocations(['email' => 'mini@me.com'], ['email' => 'mini@me.org'], 'Email', [
817 [
818 'email' => 'anthony_anderson@civicrm.org',
819 'location_type_id' => 'Home',
820 ],
821 ]));
822 return $data;
823
824 }
825
826 /**
827 * Test weird characters don't mess with merge & cause a fatal.
828 *
829 * @throws \CRM_Core_Exception
830 */
831 public function testNoErrorOnOdd() {
832 $this->individualCreate();
833 $this->individualCreate(['first_name' => 'Gerrit%0a%2e%0a']);
834 $this->callAPISuccess('Job', 'process_batch_merge', []);
835
836 $this->individualCreate();
837 $this->individualCreate(['first_name' => '[foo\\bar\'baz']);
838 $this->callAPISuccess('Job', 'process_batch_merge', []);
839 $this->callAPISuccessGetSingle('Contact', ['first_name' => '[foo\\bar\'baz']);
840 }
841
842 /**
843 * Test the batch merge does not create duplicate emails.
844 *
845 * Test CRM-18546, a 4.7 regression whereby a merged contact gets duplicate emails.
846 *
847 * @throws \CRM_Core_Exception
848 */
849 public function testBatchMergeEmailHandling() {
850 for ($x = 0; $x <= 4; $x++) {
851 $id = $this->individualCreate(['email' => 'batman@gotham.met']);
852 }
853 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
854 $this->assertCount(4, $result['values']['merged']);
855 $this->callAPISuccessGetCount('Contact', ['email' => 'batman@gotham.met'], 1);
856 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
857 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 1]);
858 $this->callAPISuccessGetCount('Email', [
859 'email' => 'batman@gotham.met',
860 'contact_id' => ['IN' => array_keys($contacts['values'])],
861 ], 1);
862 $this->callAPISuccessGetCount('Email', [
863 'email' => 'batman@gotham.met',
864 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
865 ], 4);
866 }
867
868 /**
869 * Test the batch merge respects email "on hold".
870 *
871 * Test CRM-19148, Batch merge - Email on hold data lost when there is a conflict.
872 *
873 * @dataProvider getOnHoldSets
874 *
875 * @param bool $onHold1
876 * @param bool $onHold2
877 * @param bool $merge
878 * @param string $conflictText
879 *
880 * @throws \CRM_Core_Exception
881 */
882 public function testBatchMergeEmailOnHold($onHold1, $onHold2, $merge, $conflictText) {
883 $this->individualCreate([
884 'api.email.create' => [
885 'email' => 'batman@gotham.met',
886 'location_type_id' => 'Work',
887 'is_primary' => 1,
888 'on_hold' => $onHold1,
889 ],
890 ]);
891 $this->individualCreate([
892 'api.email.create' => [
893 'email' => 'batman@gotham.met',
894 'location_type_id' => 'Work',
895 'is_primary' => 1,
896 'on_hold' => $onHold2,
897 ],
898 ]);
899 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
900 $this->assertCount($merge, $result['values']['merged']);
901 if ($conflictText) {
902 $defaultRuleGroupID = $this->callAPISuccessGetValue('RuleGroup', [
903 'contact_type' => 'Individual',
904 'used' => 'Unsupervised',
905 'return' => 'id',
906 'options' => ['limit' => 1],
907 ]);
908
909 $duplicates = $this->callAPISuccess('Dedupe', 'getduplicates', ['rule_group_id' => $defaultRuleGroupID]);
910 $this->assertEquals($conflictText, $duplicates['values'][0]['conflicts']);
911 }
912 }
913
914 /**
915 * Data provider for testBatchMergeEmailOnHold: combinations of on_hold & expected outcomes.
916 */
917 public function getOnHoldSets() {
918 // Each row specifies: contact 1 on_hold, contact 2 on_hold, merge? (0 or 1),
919 return [
920 [0, 0, 1, NULL],
921 [0, 1, 0, "Email 2 (Work): 'batman@gotham.met' vs. 'batman@gotham.met\n(On Hold)'"],
922 [1, 0, 0, "Email 2 (Work): 'batman@gotham.met\n(On Hold)' vs. 'batman@gotham.met'"],
923 [1, 1, 1, NULL],
924 ];
925 }
926
927 /**
928 * Test the batch merge does not fatal on an empty rule.
929 *
930 * @dataProvider getRuleSets
931 *
932 * @param string $contactType
933 * @param string $used
934 * @param string $name
935 * @param bool $isReserved
936 * @param int $threshold
937 *
938 * @throws \CRM_Core_Exception
939 */
940 public function testBatchMergeEmptyRule($contactType, $used, $name, $isReserved, $threshold) {
941 $ruleGroup = $this->callAPISuccess('RuleGroup', 'create', [
942 'contact_type' => $contactType,
943 'threshold' => $threshold,
944 'used' => $used,
945 'name' => $name,
946 'is_reserved' => $isReserved,
947 ]);
948 $this->callAPISuccess('Job', 'process_batch_merge', ['rule_group_id' => $ruleGroup['id']]);
949 $this->callAPISuccess('RuleGroup', 'delete', ['id' => $ruleGroup['id']]);
950 }
951
952 /**
953 * Get the various rule combinations.
954 */
955 public function getRuleSets() {
956 $contactTypes = ['Individual', 'Organization', 'Household'];
957 $useds = ['Unsupervised', 'General', 'Supervised'];
958 $ruleGroups = [];
959 foreach ($contactTypes as $contactType) {
960 foreach ($useds as $used) {
961 $ruleGroups[] = [$contactType, $used, 'Bob', FALSE, 0];
962 $ruleGroups[] = [$contactType, $used, 'Bob', FALSE, 10];
963 $ruleGroups[] = [$contactType, $used, 'Bob', TRUE, 10];
964 $ruleGroups[] = [$contactType, $used, $contactType . $used, FALSE, 10];
965 $ruleGroups[] = [$contactType, $used, $contactType . $used, TRUE, 10];
966 }
967 }
968 return $ruleGroups;
969 }
970
971 /**
972 * Test the batch merge does not create duplicate emails.
973 *
974 * Test CRM-18546, a 4.7 regression whereby a merged contact gets duplicate emails.
975 *
976 * @throws \CRM_Core_Exception
977 */
978 public function testBatchMergeMatchingAddress() {
979 for ($x = 0; $x <= 2; $x++) {
980 $this->individualCreate([
981 'api.address.create' => [
982 'location_type_id' => 'Home',
983 'street_address' => 'Appt 115, The Batcave',
984 'city' => 'Gotham',
985 'postal_code' => 'Nananananana',
986 ],
987 ]);
988 }
989 // Different location type, still merge, identical.
990 $this->individualCreate([
991 'api.address.create' => [
992 'location_type_id' => 'Main',
993 'street_address' => 'Appt 115, The Batcave',
994 'city' => 'Gotham',
995 'postal_code' => 'Nananananana',
996 ],
997 ]);
998
999 $this->individualCreate([
1000 'api.address.create' => [
1001 'location_type_id' => 'Home',
1002 'street_address' => 'Appt 115, The Batcave',
1003 'city' => 'Gotham',
1004 'postal_code' => 'Batman',
1005 ],
1006 ]);
1007
1008 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
1009 $this->assertEquals(3, count($result['values']['merged']));
1010 $this->assertEquals(1, count($result['values']['skipped']));
1011 $this->callAPISuccessGetCount('Contact', ['street_address' => 'Appt 115, The Batcave'], 2);
1012 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
1013 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 1]);
1014 $this->callAPISuccessGetCount('Address', [
1015 'street_address' => 'Appt 115, The Batcave',
1016 'contact_id' => ['IN' => array_keys($contacts['values'])],
1017 ], 3);
1018
1019 $this->callAPISuccessGetCount('Address', [
1020 'street_address' => 'Appt 115, The Batcave',
1021 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
1022 ], 2);
1023 }
1024
1025 /**
1026 * Test the batch merge by id range.
1027 *
1028 * We have 2 sets of 5 matches & set the merge only to merge the lower set.
1029 *
1030 * @throws \CRM_Core_Exception
1031 */
1032 public function testBatchMergeIDRange() {
1033 for ($x = 0; $x <= 4; $x++) {
1034 $id = $this->individualCreate(['email' => 'batman@gotham.met']);
1035 }
1036 for ($x = 0; $x <= 4; $x++) {
1037 $this->individualCreate(['email' => 'robin@gotham.met']);
1038 }
1039 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['criteria' => ['contact' => ['id' => ['<' => $id]]]]);
1040 $this->assertEquals(4, count($result['values']['merged']));
1041 $this->callAPISuccessGetCount('Contact', ['email' => 'batman@gotham.met'], 1);
1042 $this->callAPISuccessGetCount('Contact', ['email' => 'robin@gotham.met'], 5);
1043 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
1044 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
1045 $this->callAPISuccessGetCount('Email', [
1046 'email' => 'batman@gotham.met',
1047 'contact_id' => ['IN' => array_keys($contacts['values'])],
1048 ], 1);
1049 $this->callAPISuccessGetCount('Email', [
1050 'email' => 'batman@gotham.met',
1051 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
1052 ], 1);
1053 $this->callAPISuccessGetCount('Email', [
1054 'email' => 'robin@gotham.met',
1055 'contact_id' => ['IN' => array_keys($contacts['values'])],
1056 ], 5);
1057
1058 }
1059
1060 /**
1061 * Test the batch merge copes with view only custom data field.
1062 *
1063 * @throws \CRM_Core_Exception
1064 */
1065 public function testBatchMergeCustomDataViewOnlyField() {
1066 CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit my contact'];
1067 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1068 $this->individualCreate($mouseParams);
1069
1070 $customGroup = $this->customGroupCreate();
1071 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'is_view' => 1]);
1072 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 'blah']));
1073
1074 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1075 $this->assertEquals(1, count($result['values']['merged']));
1076 $mouseParams['return'] = 'custom_' . $customField['id'];
1077 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1078 $this->assertEquals('blah', $mouse['custom_' . $customField['id']]);
1079
1080 $this->customFieldDelete($customField['id']);
1081 $this->customGroupDelete($customGroup['id']);
1082 }
1083
1084 /**
1085 * Test the batch merge retains 0 as a valid custom field value.
1086 *
1087 * Note that we set 0 on 2 fields with one on each contact to ensure that
1088 * both merged & mergee fields are respected.
1089 *
1090 * @throws \CRM_Core_Exception
1091 */
1092 public function testBatchMergeCustomDataZeroValueField() {
1093 $customGroup = $this->customGroupCreate();
1094 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'default_value' => NULL]);
1095
1096 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1097 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => '']));
1098 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 0]));
1099
1100 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1101 $this->assertCount(1, $result['values']['merged']);
1102 $mouseParams['return'] = 'custom_' . $customField['id'];
1103 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1104 $this->assertEquals(0, $mouse['custom_' . $customField['id']]);
1105
1106 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => NULL]));
1107 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1108 $this->assertEquals(1, count($result['values']['merged']));
1109 $mouseParams['return'] = 'custom_' . $customField['id'];
1110 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1111 $this->assertEquals(0, $mouse['custom_' . $customField['id']]);
1112
1113 $this->customFieldDelete($customField['id']);
1114 $this->customGroupDelete($customGroup['id']);
1115 }
1116
1117 /**
1118 * Test the batch merge treats 0 vs 1 as a conflict.
1119 *
1120 * @throws \CRM_Core_Exception
1121 */
1122 public function testBatchMergeCustomDataZeroValueFieldWithConflict() {
1123 $customGroup = $this->customGroupCreate();
1124 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'default_value' => NULL]);
1125
1126 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1127 $mouse1 = $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 0]));
1128 $mouse2 = $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 1]));
1129
1130 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1131 $this->assertCount(0, $result['values']['merged']);
1132
1133 // Reverse which mouse has the zero to test we still get a conflict.
1134 $this->individualCreate(array_merge($mouseParams, ['id' => $mouse1, 'custom_' . $customField['id'] => 1]));
1135 $this->individualCreate(array_merge($mouseParams, ['id' => $mouse2, 'custom_' . $customField['id'] => 0]));
1136 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1137 $this->assertEquals(0, count($result['values']['merged']));
1138
1139 $this->customFieldDelete($customField['id']);
1140 $this->customGroupDelete($customGroup['id']);
1141 }
1142
1143 /**
1144 * Test the batch merge function actually works!
1145 *
1146 * @dataProvider getMergeSets
1147 *
1148 * @param array $dataSet
1149 *
1150 * @throws \CRM_Core_Exception
1151 */
1152 public function testBatchMergeWorksCheckPermissionsTrue($dataSet) {
1153 CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'administer CiviCRM', 'merge duplicate contacts', 'force merge duplicate contacts'];
1154 foreach ($dataSet['contacts'] as $params) {
1155 $this->callAPISuccess('Contact', 'create', $params);
1156 }
1157
1158 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 1, 'mode' => $dataSet['mode']]);
1159 $this->assertCount(0, $result['values']['merged'], 'User does not have permission to any contacts, so no merging');
1160 $this->assertCount(0, $result['values']['skipped'], 'User does not have permission to any contacts, so no skip visibility');
1161 }
1162
1163 /**
1164 * Test the batch merge function actually works!
1165 *
1166 * @dataProvider getMergeSets
1167 *
1168 * @param array $dataSet
1169 *
1170 * @throws \CRM_Core_Exception
1171 */
1172 public function testBatchMergeWorksCheckPermissionsFalse($dataSet) {
1173 CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit my contact'];
1174 foreach ($dataSet['contacts'] as $params) {
1175 $this->callAPISuccess('Contact', 'create', $params);
1176 }
1177
1178 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => $dataSet['mode']]);
1179 $this->assertEquals($dataSet['skipped'], count($result['values']['skipped']), 'Failed to skip the right number:' . $dataSet['skipped']);
1180 $this->assertEquals($dataSet['merged'], count($result['values']['merged']));
1181 }
1182
1183 /**
1184 * Get data for batch merge.
1185 */
1186 public function getMergeSets() {
1187 $data = [
1188 [
1189 [
1190 'mode' => 'safe',
1191 'contacts' => [
1192 [
1193 'first_name' => 'Michael',
1194 'last_name' => 'Jackson',
1195 'email' => 'michael@neverland.com',
1196 'contact_type' => 'Individual',
1197 'contact_sub_type' => 'Student',
1198 'api.Address.create' => [
1199 'street_address' => 'big house',
1200 'location_type_id' => 'Home',
1201 ],
1202 ],
1203 [
1204 'first_name' => 'Michael',
1205 'last_name' => 'Jackson',
1206 'email' => 'michael@neverland.com',
1207 'contact_type' => 'Individual',
1208 'contact_sub_type' => 'Student',
1209 ],
1210 ],
1211 'skipped' => 0,
1212 'merged' => 1,
1213 'expected' => [
1214 [
1215 'first_name' => 'Michael',
1216 'last_name' => 'Jackson',
1217 'email' => 'michael@neverland.com',
1218 'contact_type' => 'Individual',
1219 ],
1220 ],
1221 ],
1222 ],
1223 [
1224 [
1225 'mode' => 'safe',
1226 'contacts' => [
1227 [
1228 'first_name' => 'Michael',
1229 'last_name' => 'Jackson',
1230 'email' => 'michael@neverland.com',
1231 'contact_type' => 'Individual',
1232 'contact_sub_type' => 'Student',
1233 'api.Address.create' => [
1234 'street_address' => 'big house',
1235 'location_type_id' => 'Home',
1236 ],
1237 ],
1238 [
1239 'first_name' => 'Michael',
1240 'last_name' => 'Jackson',
1241 'email' => 'michael@neverland.com',
1242 'contact_type' => 'Individual',
1243 'contact_sub_type' => 'Student',
1244 'api.Address.create' => [
1245 'street_address' => 'bigger house',
1246 'location_type_id' => 'Home',
1247 ],
1248 ],
1249 ],
1250 'skipped' => 1,
1251 'merged' => 0,
1252 'expected' => [
1253 [
1254 'first_name' => 'Michael',
1255 'last_name' => 'Jackson',
1256 'email' => 'michael@neverland.com',
1257 'contact_type' => 'Individual',
1258 'street_address' => 'big house',
1259 ],
1260 [
1261 'first_name' => 'Michael',
1262 'last_name' => 'Jackson',
1263 'email' => 'michael@neverland.com',
1264 'contact_type' => 'Individual',
1265 'street_address' => 'bigger house',
1266 ],
1267 ],
1268 ],
1269 ],
1270 [
1271 [
1272 'mode' => 'safe',
1273 'contacts' => [
1274 [
1275 'first_name' => 'Michael',
1276 'last_name' => 'Jackson',
1277 'email' => 'michael@neverland.com',
1278 'contact_type' => 'Individual',
1279 'contact_sub_type' => 'Student',
1280 'api.Email.create' => [
1281 'email' => 'big.slog@work.co.nz',
1282 'location_type_id' => 'Work',
1283 ],
1284 ],
1285 [
1286 'first_name' => 'Michael',
1287 'last_name' => 'Jackson',
1288 'email' => 'michael@neverland.com',
1289 'contact_type' => 'Individual',
1290 'contact_sub_type' => 'Student',
1291 'api.Email.create' => [
1292 'email' => 'big.slog@work.com',
1293 'location_type_id' => 'Work',
1294 ],
1295 ],
1296 ],
1297 'skipped' => 1,
1298 'merged' => 0,
1299 'expected' => [
1300 [
1301 'first_name' => 'Michael',
1302 'last_name' => 'Jackson',
1303 'email' => 'michael@neverland.com',
1304 'contact_type' => 'Individual',
1305 ],
1306 [
1307 'first_name' => 'Michael',
1308 'last_name' => 'Jackson',
1309 'email' => 'michael@neverland.com',
1310 'contact_type' => 'Individual',
1311 ],
1312 ],
1313 ],
1314 ],
1315 [
1316 [
1317 'mode' => 'safe',
1318 'contacts' => [
1319 [
1320 'first_name' => 'Michael',
1321 'last_name' => 'Jackson',
1322 'email' => 'michael@neverland.com',
1323 'contact_type' => 'Individual',
1324 'contact_sub_type' => 'Student',
1325 'api.Phone.create' => [
1326 'phone' => '123456',
1327 'location_type_id' => 'Work',
1328 ],
1329 ],
1330 [
1331 'first_name' => 'Michael',
1332 'last_name' => 'Jackson',
1333 'email' => 'michael@neverland.com',
1334 'contact_type' => 'Individual',
1335 'contact_sub_type' => 'Student',
1336 'api.Phone.create' => [
1337 'phone' => '23456',
1338 'location_type_id' => 'Work',
1339 ],
1340 ],
1341 ],
1342 'skipped' => 1,
1343 'merged' => 0,
1344 'expected' => [
1345 [
1346 'first_name' => 'Michael',
1347 'last_name' => 'Jackson',
1348 'email' => 'michael@neverland.com',
1349 'contact_type' => 'Individual',
1350 ],
1351 [
1352 'first_name' => 'Michael',
1353 'last_name' => 'Jackson',
1354 'email' => 'michael@neverland.com',
1355 'contact_type' => 'Individual',
1356 ],
1357 ],
1358 ],
1359 ],
1360 [
1361 [
1362 'mode' => 'aggressive',
1363 'contacts' => [
1364 [
1365 'first_name' => 'Michael',
1366 'last_name' => 'Jackson',
1367 'email' => 'michael@neverland.com',
1368 'contact_type' => 'Individual',
1369 'contact_sub_type' => 'Student',
1370 'api.Address.create' => [
1371 'street_address' => 'big house',
1372 'location_type_id' => 'Home',
1373 ],
1374 ],
1375 [
1376 'first_name' => 'Michael',
1377 'last_name' => 'Jackson',
1378 'email' => 'michael@neverland.com',
1379 'contact_type' => 'Individual',
1380 'contact_sub_type' => 'Student',
1381 'api.Address.create' => [
1382 'street_address' => 'bigger house',
1383 'location_type_id' => 'Home',
1384 ],
1385 ],
1386 ],
1387 'skipped' => 0,
1388 'merged' => 1,
1389 'expected' => [
1390 [
1391 'first_name' => 'Michael',
1392 'last_name' => 'Jackson',
1393 'email' => 'michael@neverland.com',
1394 'contact_type' => 'Individual',
1395 'street_address' => 'big house',
1396 ],
1397 ],
1398 ],
1399 ],
1400 [
1401 [
1402 'mode' => 'safe',
1403 'contacts' => [
1404 [
1405 'first_name' => 'Michael',
1406 'last_name' => 'Jackson',
1407 'email' => 'michael@neverland.com',
1408 'contact_type' => 'Individual',
1409 'contact_sub_type' => 'Student',
1410 'api.Address.create' => [
1411 'street_address' => 'big house',
1412 'location_type_id' => 'Home',
1413 ],
1414 ],
1415 [
1416 'first_name' => 'Michael',
1417 'last_name' => 'Jackson',
1418 'email' => 'michael@neverland.com',
1419 'contact_type' => 'Individual',
1420 'contact_sub_type' => 'Student',
1421 'is_deceased' => 1,
1422 ],
1423 ],
1424 'skipped' => 1,
1425 'merged' => 0,
1426 'expected' => [
1427 [
1428 'first_name' => 'Michael',
1429 'last_name' => 'Jackson',
1430 'email' => 'michael@neverland.com',
1431 'contact_type' => 'Individual',
1432 'is_deceased' => 0,
1433 ],
1434 [
1435 'first_name' => 'Michael',
1436 'last_name' => 'Jackson',
1437 'email' => 'michael@neverland.com',
1438 'contact_type' => 'Individual',
1439 'is_deceased' => 1,
1440 ],
1441 ],
1442 ],
1443 ],
1444 [
1445 [
1446 'mode' => 'safe',
1447 'contacts' => [
1448 [
1449 'first_name' => 'Michael',
1450 'last_name' => 'Jackson',
1451 'email' => 'michael@neverland.com',
1452 'contact_type' => 'Individual',
1453 'contact_sub_type' => 'Student',
1454 'api.Address.create' => [
1455 'street_address' => 'big house',
1456 'location_type_id' => 'Home',
1457 ],
1458 'is_deceased' => 1,
1459 ],
1460 [
1461 'first_name' => 'Michael',
1462 'last_name' => 'Jackson',
1463 'email' => 'michael@neverland.com',
1464 'contact_type' => 'Individual',
1465 'contact_sub_type' => 'Student',
1466 ],
1467 ],
1468 'skipped' => 1,
1469 'merged' => 0,
1470 'expected' => [
1471 [
1472 'first_name' => 'Michael',
1473 'last_name' => 'Jackson',
1474 'email' => 'michael@neverland.com',
1475 'contact_type' => 'Individual',
1476 'is_deceased' => 1,
1477 ],
1478 [
1479 'first_name' => 'Michael',
1480 'last_name' => 'Jackson',
1481 'email' => 'michael@neverland.com',
1482 'contact_type' => 'Individual',
1483 'is_deceased' => 0,
1484 ],
1485 ],
1486 ],
1487 ],
1488 ];
1489
1490 $conflictPairs = [
1491 'first_name' => 'Dianna',
1492 'last_name' => 'McAndrew',
1493 'middle_name' => 'Prancer',
1494 'birth_date' => '2015-12-25',
1495 'gender_id' => 'Female',
1496 'job_title' => 'Thriller',
1497 ];
1498
1499 foreach ($conflictPairs as $key => $value) {
1500 $contactParams = [
1501 'first_name' => 'Michael',
1502 'middle_name' => 'Dancer',
1503 'last_name' => 'Jackson',
1504 'birth_date' => '2015-02-25',
1505 'email' => 'michael@neverland.com',
1506 'contact_type' => 'Individual',
1507 'contact_sub_type' => ['Student'],
1508 'gender_id' => 'Male',
1509 'job_title' => 'Entertainer',
1510 ];
1511 $contact2 = $contactParams;
1512
1513 $contact2[$key] = $value;
1514 $data[$key . '_conflict'] = [
1515 [
1516 'mode' => 'safe',
1517 'contacts' => [$contactParams, $contact2],
1518 'skipped' => 1,
1519 'merged' => 0,
1520 'expected' => [$contactParams, $contact2],
1521 ],
1522 ];
1523 }
1524
1525 return $data;
1526 }
1527
1528 /**
1529 * Implements pre hook on relationships.
1530 *
1531 * @param string $op
1532 * @param string $objectName
1533 * @param int $id
1534 * @param array $params
1535 */
1536 public function hookPreRelationship($op, $objectName, $id, &$params) {
1537 if ($op === 'delete') {
1538 return;
1539 }
1540 if ($params['is_active']) {
1541 $params['description'] = 'Hooked';
1542 }
1543 else {
1544 $params['description'] = 'Go Go you good thing';
1545 }
1546 }
1547
1548 /**
1549 * Get the location data set.
1550 *
1551 * @param array $locationParams1
1552 * @param array $locationParams2
1553 * @param string $entity
1554 * @param array $additionalExpected
1555 *
1556 * @return array
1557 */
1558 public function getMergeLocations($locationParams1, $locationParams2, $entity, $additionalExpected = []) {
1559 return [
1560 [
1561 'matching_primary' => [
1562 'entity' => $entity,
1563 'contact_1' => [
1564 array_merge([
1565 'location_type_id' => 'Main',
1566 'is_primary' => 1,
1567 ], $locationParams1),
1568 array_merge([
1569 'location_type_id' => 'Work',
1570 'is_primary' => 0,
1571 ], $locationParams2),
1572 ],
1573 'contact_2' => [
1574 array_merge([
1575 'location_type_id' => 'Main',
1576 'is_primary' => 1,
1577 ], $locationParams1),
1578 ],
1579 'expected' => array_merge($additionalExpected, [
1580 array_merge([
1581 'location_type_id' => 'Main',
1582 'is_primary' => 1,
1583 ], $locationParams1),
1584 array_merge([
1585 'location_type_id' => 'Work',
1586 'is_primary' => 0,
1587 ], $locationParams2),
1588 ]),
1589 'expected_hook' => array_merge($additionalExpected, [
1590 array_merge([
1591 'location_type_id' => 'Main',
1592 'is_primary' => 1,
1593 ], $locationParams1),
1594 array_merge([
1595 'location_type_id' => 'Work',
1596 'is_primary' => 0,
1597 ], $locationParams2),
1598 ]),
1599 ],
1600 ],
1601 [
1602 'matching_primary_reverse' => [
1603 'entity' => $entity,
1604 'contact_1' => [
1605 array_merge([
1606 'location_type_id' => 'Main',
1607 'is_primary' => 1,
1608 ], $locationParams1),
1609 ],
1610 'contact_2' => [
1611 array_merge([
1612 'location_type_id' => 'Main',
1613 'is_primary' => 1,
1614 ], $locationParams1),
1615 array_merge([
1616 'location_type_id' => 'Work',
1617 'is_primary' => 0,
1618 ], $locationParams2),
1619 ],
1620 'expected' => array_merge($additionalExpected, [
1621 array_merge([
1622 'location_type_id' => 'Main',
1623 'is_primary' => 1,
1624 ], $locationParams1),
1625 array_merge([
1626 'location_type_id' => 'Work',
1627 'is_primary' => 0,
1628 ], $locationParams2),
1629 ]),
1630 'expected_hook' => array_merge($additionalExpected, [
1631 array_merge([
1632 'location_type_id' => 'Main',
1633 'is_primary' => 1,
1634 ], $locationParams1),
1635 array_merge([
1636 'location_type_id' => 'Work',
1637 'is_primary' => 0,
1638 ], $locationParams2),
1639 ]),
1640 ],
1641 ],
1642 [
1643 'only_one_has_address' => [
1644 'entity' => $entity,
1645 'contact_1' => [
1646 array_merge([
1647 'location_type_id' => 'Main',
1648 'is_primary' => 1,
1649 ], $locationParams1),
1650 array_merge([
1651 'location_type_id' => 'Work',
1652 'is_primary' => 0,
1653 ], $locationParams2),
1654 ],
1655 'contact_2' => [],
1656 'expected' => array_merge($additionalExpected, [
1657 array_merge([
1658 'location_type_id' => 'Main',
1659 'is_primary' => 1,
1660 ], $locationParams1),
1661 array_merge([
1662 'location_type_id' => 'Work',
1663 'is_primary' => 0,
1664 ], $locationParams2),
1665 ]),
1666 'expected_hook' => array_merge($additionalExpected, [
1667 array_merge([
1668 'location_type_id' => 'Main',
1669 // When dealing with email we don't have a clean slate - the existing
1670 // primary will be primary.
1671 'is_primary' => ($entity == 'Email' ? 0 : 1),
1672 ], $locationParams1),
1673 array_merge([
1674 'location_type_id' => 'Work',
1675 'is_primary' => 0,
1676 ], $locationParams2),
1677 ]),
1678 ],
1679 ],
1680 [
1681 'only_one_has_address_reverse' => [
1682 'description' => 'The destination contact does not have an address. secondary contact should be merged in.',
1683 'entity' => $entity,
1684 'contact_1' => [],
1685 'contact_2' => [
1686 array_merge([
1687 'location_type_id' => 'Main',
1688 'is_primary' => 1,
1689 ], $locationParams1),
1690 array_merge([
1691 'location_type_id' => 'Work',
1692 'is_primary' => 0,
1693 ], $locationParams2),
1694 ],
1695 'expected' => array_merge($additionalExpected, [
1696 array_merge([
1697 'location_type_id' => 'Main',
1698 // When dealing with email we don't have a clean slate - the existing
1699 // primary will be primary.
1700 'is_primary' => ($entity == 'Email' ? 0 : 1),
1701 ], $locationParams1),
1702 array_merge([
1703 'location_type_id' => 'Work',
1704 'is_primary' => 0,
1705 ], $locationParams2),
1706 ]),
1707 'expected_hook' => array_merge($additionalExpected, [
1708 array_merge([
1709 'location_type_id' => 'Main',
1710 'is_primary' => 1,
1711 ], $locationParams1),
1712 array_merge([
1713 'location_type_id' => 'Work',
1714 'is_primary' => 0,
1715 ], $locationParams2),
1716 ]),
1717 ],
1718 ],
1719 [
1720 'different_primaries_with_different_location_type' => [
1721 'description' => 'Primaries are different with different location. Keep both addresses. Set primary to be that of lower id',
1722 'entity' => $entity,
1723 'contact_1' => [
1724 array_merge([
1725 'location_type_id' => 'Main',
1726 'is_primary' => 1,
1727 ], $locationParams1),
1728 ],
1729 'contact_2' => [
1730 array_merge([
1731 'location_type_id' => 'Work',
1732 'is_primary' => 1,
1733 ], $locationParams2),
1734 ],
1735 'expected' => array_merge($additionalExpected, [
1736 array_merge([
1737 'location_type_id' => 'Main',
1738 'is_primary' => 1,
1739 ], $locationParams1),
1740 array_merge([
1741 'location_type_id' => 'Work',
1742 'is_primary' => 0,
1743 ], $locationParams2),
1744 ]),
1745 'expected_hook' => array_merge($additionalExpected, [
1746 array_merge([
1747 'location_type_id' => 'Main',
1748 'is_primary' => 0,
1749 ], $locationParams1),
1750 array_merge([
1751 'location_type_id' => 'Work',
1752 'is_primary' => 1,
1753 ], $locationParams2),
1754 ]),
1755 ],
1756 ],
1757 [
1758 'different_primaries_with_different_location_type_reverse' => [
1759 'entity' => $entity,
1760 'contact_1' => [
1761 array_merge([
1762 'location_type_id' => 'Work',
1763 'is_primary' => 1,
1764 ], $locationParams2),
1765 ],
1766 'contact_2' => [
1767 array_merge([
1768 'location_type_id' => 'Main',
1769 'is_primary' => 1,
1770 ], $locationParams1),
1771 ],
1772 'expected' => array_merge($additionalExpected, [
1773 array_merge([
1774 'location_type_id' => 'Work',
1775 'is_primary' => 1,
1776 ], $locationParams2),
1777 array_merge([
1778 'location_type_id' => 'Main',
1779 'is_primary' => 0,
1780 ], $locationParams1),
1781 ]),
1782 'expected_hook' => array_merge($additionalExpected, [
1783 array_merge([
1784 'location_type_id' => 'Work',
1785 'is_primary' => 0,
1786 ], $locationParams2),
1787 array_merge([
1788 'location_type_id' => 'Main',
1789 'is_primary' => 1,
1790 ], $locationParams1),
1791 ]),
1792 ],
1793 ],
1794 [
1795 'different_primaries_location_match_only_one_address' => [
1796 'entity' => $entity,
1797 'contact_1' => [
1798 array_merge([
1799 'location_type_id' => 'Main',
1800 'is_primary' => 1,
1801 ], $locationParams1),
1802 array_merge([
1803 'location_type_id' => 'Work',
1804 'is_primary' => 0,
1805 ], $locationParams2),
1806 ],
1807 'contact_2' => [
1808 array_merge([
1809 'location_type_id' => 'Work',
1810 'is_primary' => 1,
1811 ], $locationParams2),
1812
1813 ],
1814 'expected' => array_merge($additionalExpected, [
1815 array_merge([
1816 'location_type_id' => 'Main',
1817 'is_primary' => 1,
1818 ], $locationParams1),
1819 array_merge([
1820 'location_type_id' => 'Work',
1821 'is_primary' => 0,
1822 ], $locationParams2),
1823 ]),
1824 'expected_hook' => array_merge($additionalExpected, [
1825 array_merge([
1826 'location_type_id' => 'Main',
1827 'is_primary' => 0,
1828 ], $locationParams1),
1829 array_merge([
1830 'location_type_id' => 'Work',
1831 'is_primary' => 1,
1832 ], $locationParams2),
1833 ]),
1834 ],
1835 ],
1836 [
1837 'different_primaries_location_match_only_one_address_reverse' => [
1838 'entity' => $entity,
1839 'contact_1' => [
1840 array_merge([
1841 'location_type_id' => 'Work',
1842 'is_primary' => 1,
1843 ], $locationParams2),
1844 ],
1845 'contact_2' => [
1846 array_merge([
1847 'location_type_id' => 'Main',
1848 'is_primary' => 1,
1849 ], $locationParams1),
1850 array_merge([
1851 'location_type_id' => 'Work',
1852 'is_primary' => 0,
1853 ], $locationParams2),
1854 ],
1855 'expected' => array_merge($additionalExpected, [
1856 array_merge([
1857 'location_type_id' => 'Work',
1858 'is_primary' => 1,
1859 ], $locationParams2),
1860 array_merge([
1861 'location_type_id' => 'Main',
1862 'is_primary' => 0,
1863 ], $locationParams1),
1864 ]),
1865 'expected_hook' => array_merge($additionalExpected, [
1866 array_merge([
1867 'location_type_id' => 'Work',
1868 'is_primary' => 0,
1869 ], $locationParams2),
1870 array_merge([
1871 'location_type_id' => 'Main',
1872 'is_primary' => 1,
1873 ], $locationParams1),
1874 ]),
1875 ],
1876 ],
1877 [
1878 'same_primaries_different_location' => [
1879 'entity' => $entity,
1880 'contact_1' => [
1881 array_merge([
1882 'location_type_id' => 'Main',
1883 'is_primary' => 1,
1884 ], $locationParams1),
1885 ],
1886 'contact_2' => [
1887 array_merge([
1888 'location_type_id' => 'Work',
1889 'is_primary' => 1,
1890 ], $locationParams1),
1891
1892 ],
1893 'expected' => array_merge($additionalExpected, [
1894 array_merge([
1895 'location_type_id' => 'Main',
1896 'is_primary' => 1,
1897 ], $locationParams1),
1898 array_merge([
1899 'location_type_id' => 'Work',
1900 'is_primary' => 0,
1901 ], $locationParams1),
1902 ]),
1903 'expected_hook' => array_merge($additionalExpected, [
1904 array_merge([
1905 'location_type_id' => 'Work',
1906 'is_primary' => 1,
1907 ], $locationParams1),
1908 ]),
1909 ],
1910 ],
1911 [
1912 'same_primaries_different_location_reverse' => [
1913 'entity' => $entity,
1914 'contact_1' => [
1915 array_merge([
1916 'location_type_id' => 'Work',
1917 'is_primary' => 1,
1918 ], $locationParams1),
1919 ],
1920 'contact_2' => [
1921 array_merge([
1922 'location_type_id' => 'Main',
1923 'is_primary' => 1,
1924 ], $locationParams1),
1925 ],
1926 'expected' => array_merge($additionalExpected, [
1927 array_merge([
1928 'location_type_id' => 'Work',
1929 'is_primary' => 1,
1930 ], $locationParams1),
1931 array_merge([
1932 'location_type_id' => 'Main',
1933 'is_primary' => 0,
1934 ], $locationParams1),
1935 ]),
1936 'expected_hook' => array_merge($additionalExpected, [
1937 array_merge([
1938 'location_type_id' => 'Main',
1939 'is_primary' => 1,
1940 ], $locationParams1),
1941 ]),
1942 ],
1943 ],
1944 ];
1945 }
1946
1947 /**
1948 * Test processing membership for deceased contacts.
1949 *
1950 * @throws \CRM_Core_Exception
1951 */
1952 public function testProcessMembershipDeceased() {
1953 $this->callAPISuccess('Job', 'process_membership', []);
1954 $deadManWalkingID = $this->individualCreate();
1955 $membershipID = $this->contactMembershipCreate(['contact_id' => $deadManWalkingID]);
1956 $this->callAPISuccess('Contact', 'create', ['id' => $deadManWalkingID, 'is_deceased' => 1]);
1957 $this->callAPISuccess('Job', 'process_membership', []);
1958 $membership = $this->callAPISuccessGetSingle('Membership', ['id' => $membershipID]);
1959 $deceasedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Member_BAO_Membership', 'status_id', 'Deceased');
1960 $this->assertEquals($deceasedStatusId, $membership['status_id']);
1961 }
1962
1963 /**
1964 * Test we get an error is deceased status is disabled.
1965 *
1966 * @throws \CRM_Core_Exception
1967 */
1968 public function testProcessMembershipNoDeceasedStatus() {
1969 $deceasedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Member_BAO_Membership', 'status_id', 'Deceased');
1970 $this->callAPISuccess('MembershipStatus', 'create', ['is_active' => 0, 'id' => $deceasedStatusId]);
1971 CRM_Core_PseudoConstant::flush();
1972
1973 $deadManWalkingID = $this->individualCreate();
1974 $this->contactMembershipCreate(['contact_id' => $deadManWalkingID]);
1975 $this->callAPISuccess('Contact', 'create', ['id' => $deadManWalkingID, 'is_deceased' => 1]);
1976 $this->callAPIFailure('Job', 'process_membership', []);
1977
1978 $this->callAPISuccess('MembershipStatus', 'create', ['is_active' => 1, 'id' => $deceasedStatusId]);
1979 }
1980
1981 /**
1982 * Test processing membership: check that status is updated when it should be
1983 * and left alone when it shouldn't.
1984 *
1985 * @throws \CRM_Core_Exception
1986 * @throws \CiviCRM_API3_Exception
1987 */
1988 public function testProcessMembershipUpdateStatus() {
1989 $this->ids['MembershipType'] = $this->membershipTypeCreate();
1990
1991 // Create admin-only membership status and get all statuses.
1992 $this->callAPISuccess('membership_status', 'create', ['name' => 'Admin', 'is_admin' => 1])['id'];
1993
1994 // Create membership with incorrect statuses for the given dates and also some (pending, cancelled, admin override) which should not be updated.
1995 $memberships = [
1996 [
1997 'start_date' => 'now',
1998 'end_date' => '+ 1 year',
1999 'initial_status' => 'Current',
2000 'expected_processed_status' => 'New',
2001 ],
2002 [
2003 'start_date' => '- 6 month',
2004 'end_date' => '+ 6 month',
2005 'initial_status' => 'New',
2006 'expected_processed_status' => 'Current',
2007 ],
2008 [
2009 'start_date' => '- 53 week',
2010 'end_date' => '-1 week',
2011 'initial_status' => 'Current',
2012 'expected_processed_status' => 'Grace',
2013 ],
2014 [
2015 'start_date' => '- 16 month',
2016 'end_date' => '- 4 month',
2017 'initial_status' => 'Grace',
2018 'expected_processed_status' => 'Expired',
2019 ],
2020 [
2021 'start_date' => 'now',
2022 'end_date' => '+ 1 year',
2023 'initial_status' => 'Pending',
2024 'expected_processed_status' => 'Pending',
2025 ],
2026 [
2027 'start_date' => '- 6 month',
2028 'end_date' => '+ 6 month',
2029 'initial_status' => 'Cancelled',
2030 'expected_processed_status' => 'Cancelled',
2031 ],
2032 [
2033 'start_date' => '- 16 month',
2034 'end_date' => '- 4 month',
2035 'initial_status' => 'Current',
2036 'is_override' => TRUE,
2037 'expected_processed_status' => 'Current',
2038 ],
2039 [
2040 // @todo this looks like it's covering something up. If we pass isAdminOverride it is the same as the line above. Without it the test fails.
2041 // this smells of something that should work (or someone thought should work & so put in a test) doesn't & test has been adjusted to cope.
2042 'start_date' => '- 16 month',
2043 'end_date' => '- 4 month',
2044 'initial_status' => 'Admin',
2045 'is_override' => TRUE,
2046 'expected_processed_status' => 'Admin',
2047 ],
2048 ];
2049 foreach ($memberships as $index => $membership) {
2050 $memberships[$index]['id'] = $this->createMembershipNeedingStatusProcessing($membership['start_date'], $membership['end_date'], $membership['initial_status'], $membership['is_override'] ?? FALSE);
2051 }
2052
2053 /*
2054 * Create membership type with inheritence and check processing of secondary memberships.
2055 */
2056 $employerRelationshipId = $this->callAPISuccessGetValue('RelationshipType', [
2057 'return' => 'id',
2058 'name_b_a' => 'Employer Of',
2059 ]);
2060 // Create membership type: inherited through employment.
2061 $membershipOrgId = $this->organizationCreate();
2062 $params = [
2063 'name' => 'Corporate Membership',
2064 'duration_unit' => 'year',
2065 'duration_interval' => 1,
2066 'period_type' => 'rolling',
2067 'member_of_contact_id' => $membershipOrgId,
2068 'domain_id' => 1,
2069 'financial_type_id' => 1,
2070 'relationship_type_id' => $employerRelationshipId,
2071 'relationship_direction' => 'b_a',
2072 'is_active' => 1,
2073 ];
2074 $membershipTypeId = $this->callAPISuccess('membership_type', 'create', $params)['id'];
2075
2076 // Create employer and first employee
2077 $employerId = $this->organizationCreate([], 1);
2078 $memberContactId = $this->individualCreate(['employer_id' => $employerId], 0);
2079
2080 // Create inherited membership with incorrect status but dates implying status Expired.
2081 $params = [
2082 'contact_id' => $employerId,
2083 'membership_type_id' => $membershipTypeId,
2084 'source' => 'Test suite',
2085 'join_date' => date('Y-m-d', strtotime('now - 16 month')),
2086 'start_date' => date('Y-m-d', strtotime('now - 16 month')),
2087 'end_date' => date('Y-m-d', strtotime('now - 4 month')),
2088 // Intentionally incorrect status.
2089 'status_id' => 'Grace',
2090 // Don't calculate status.
2091 'skipStatusCal' => 1,
2092 ];
2093 $organizationMembershipID = $this->contactMembershipCreate($params);
2094
2095 // Check that the employee inherited the membership and status.
2096 $expiredInheritedRelationship = $this->callAPISuccessGetSingle('membership', [
2097 'contact_id' => $memberContactId,
2098 'membership_type_id' => $membershipTypeId,
2099 ]);
2100 $this->assertEquals($organizationMembershipID, $expiredInheritedRelationship['owner_membership_id']);
2101 $this->assertMembershipStatus('Grace', (int) $expiredInheritedRelationship['status_id']);
2102
2103 // Reset static $relatedContactIds array in createRelatedMemberships(),
2104 // to avoid bug where inherited membership gets deleted.
2105 $var = TRUE;
2106 CRM_Member_BAO_Membership::createRelatedMemberships($var, $var, TRUE);
2107 // Check that after running process_membership job, statuses are correct.
2108 $this->callAPISuccess('Job', 'process_membership', []);
2109
2110 foreach ($memberships as $expectation) {
2111 $membership = $this->callAPISuccessGetSingle('membership', ['id' => $expectation['id']]);
2112 $this->assertMembershipStatus($expectation['expected_processed_status'], (int) $membership['status_id']);
2113 }
2114
2115 // Inherit Expired - should get updated.
2116 $membership = $this->callAPISuccess('membership', 'getsingle', ['id' => $expiredInheritedRelationship['id']]);
2117 $this->assertMembershipStatus('Expired', $membership['status_id']);
2118 }
2119
2120 /**
2121 * Test procesing membership where is_override is set to 0 rather than NULL
2122 *
2123 * @throws \CRM_Core_Exception
2124 */
2125 public function testProcessMembershipIsOverrideNotNullNot1either() {
2126 $membershipTypeId = $this->membershipTypeCreate();
2127
2128 // Create admin-only membership status and get all statuses.
2129 $result = $this->callAPISuccess('membership_status', 'create', ['name' => 'Admin', 'is_admin' => 1, 'sequential' => 1]);
2130 $membershipStatusIdAdmin = $result['values'][0]['id'];
2131 $memStatus = CRM_Member_PseudoConstant::membershipStatus();
2132
2133 // Default params, which we'll expand on below.
2134 $params = [
2135 'membership_type_id' => $membershipTypeId,
2136 // Don't calculate status.
2137 'skipStatusCal' => 1,
2138 'source' => 'Test',
2139 'sequential' => 1,
2140 ];
2141
2142 // Create membership with incorrect status but dates implying status Current.
2143 $params['contact_id'] = $this->individualCreate();
2144 $params['join_date'] = date('Y-m-d', strtotime('now - 6 month'));
2145 $params['start_date'] = date('Y-m-d', strtotime('now - 6 month'));
2146 $params['end_date'] = date('Y-m-d', strtotime('now + 6 month'));
2147 // Intentionally incorrect status.
2148 $params['status_id'] = 'New';
2149 $resultCurrent = $this->callAPISuccess('Membership', 'create', $params);
2150 // Ensure that is_override is set to 0 by doing through DB given API not seem to accept id
2151 CRM_Core_DAO::executeQuery("Update civicrm_membership SET is_override = 0 WHERE id = %1", [1 => [$resultCurrent['id'], 'Positive']]);
2152 $this->assertEquals(array_search('New', $memStatus, TRUE), $resultCurrent['values'][0]['status_id']);
2153 $jobResult = $this->callAPISuccess('Job', 'process_membership', []);
2154 $this->assertEquals('Processed 1 membership records. Updated 1 records.', $jobResult['values']);
2155 $this->assertEquals(array_search('Current', $memStatus, TRUE), $this->callAPISuccess('Membership', 'get', ['id' => $resultCurrent['id']])['values'][$resultCurrent['id']]['status_id']);
2156 }
2157
2158 /**
2159 * @param string $expectedStatusName
2160 * @param int $actualStatusID
2161 */
2162 protected function assertMembershipStatus(string $expectedStatusName, int $actualStatusID) {
2163 $this->assertEquals($expectedStatusName, CRM_Core_PseudoConstant::getName('CRM_Member_BAO_Membership', 'status_id', $actualStatusID));
2164 }
2165
2166 /**
2167 * @param string $startDate
2168 * Date in strtotime format - e.g 'now', '+1 day'
2169 * @param string $endDate
2170 * Date in strtotime format - e.g 'now', '+1 day'
2171 * @param string $status
2172 * Status override
2173 * @param bool $isAdminOverride
2174 * Is administratively overridden (if so the status is fixed).
2175 *
2176 * @return int
2177 *
2178 * @throws \CRM_Core_Exception
2179 */
2180 protected function createMembershipNeedingStatusProcessing(string $startDate, string $endDate, string $status, bool $isAdminOverride = FALSE): int {
2181 $params = [
2182 'membership_type_id' => $this->ids['MembershipType'],
2183 // Don't calculate status.
2184 'skipStatusCal' => 1,
2185 'source' => 'Test',
2186 'sequential' => 1,
2187 ];
2188 $params['contact_id'] = $this->individualCreate();
2189 $params['join_date'] = date('Y-m-d', strtotime($startDate));
2190 $params['start_date'] = date('Y-m-d', strtotime($startDate));
2191 $params['end_date'] = date('Y-m-d', strtotime($endDate));
2192 $params['sequential'] = TRUE;
2193 $params['is_override'] = $isAdminOverride;
2194 // Intentionally incorrect status.
2195 $params['status_id'] = $status;
2196 $resultNew = $this->callAPISuccess('Membership', 'create', $params);
2197 $this->assertMembershipStatus($status, (int) $resultNew['values'][0]['status_id']);
2198 return (int) $resultNew['id'];
2199 }
2200
2201 /**
2202 * Shared set up for SMS reminder tests.
2203 *
2204 * @return array
2205 *
2206 * @throws \CRM_Core_Exception
2207 * @throws \CiviCRM_API3_Exception
2208 */
2209 protected function setUpMembershipSMSReminders(): array {
2210 $membershipTypeID = $this->membershipTypeCreate();
2211 $this->membershipStatusCreate();
2212 $createTotal = 3;
2213 $groupID = $this->groupCreate(['name' => 'Texan drawlers', 'title' => 'a...']);
2214 for ($i = 1; $i <= $createTotal; $i++) {
2215 $contactID = $this->individualCreate();
2216 $this->callAPISuccess('Phone', 'create', [
2217 'contact_id' => $contactID,
2218 'phone' => '555 123 1234',
2219 'phone_type_id' => 'Mobile',
2220 'location_type_id' => 'Billing',
2221 ]);
2222 if ($i === 2) {
2223 $theChosenOneID = $contactID;
2224 }
2225 if ($i < 3) {
2226 $this->callAPISuccess('group_contact', 'create', [
2227 'contact_id' => $contactID,
2228 'status' => 'Added',
2229 'group_id' => $groupID,
2230 ]);
2231 }
2232 if ($i > 1) {
2233 $this->callAPISuccess('membership', 'create', [
2234 'contact_id' => $contactID,
2235 'membership_type_id' => $membershipTypeID,
2236 'join_date' => 'now',
2237 'start_date' => '+ 1 day',
2238 ]);
2239 }
2240 }
2241 $this->setupForSmsTests();
2242 $provider = civicrm_api3('SmsProvider', 'create', [
2243 'name' => 'CiviTestSMSProvider',
2244 'api_type' => '1',
2245 'username' => '1',
2246 'password' => '1',
2247 'api_url' => '1',
2248 'api_params' => 'a=1',
2249 'is_default' => '1',
2250 'is_active' => '1',
2251 'domain_id' => '1',
2252 ]);
2253 return [$membershipTypeID, $groupID, $theChosenOneID, $provider];
2254 }
2255
2256 }