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