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