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