3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
13 * File for the CiviCRM APIv3 job functions
15 * @package CiviCRM_APIv3
18 * @copyright CiviCRM LLC https://civicrm.org/licensing
22 * Class api_v3_JobTest
26 class api_v3_JobTest
extends CiviUnitTestCase
{
28 public $DBResetRequired = FALSE;
30 public $_entity = 'Job';
33 * Created membership type.
35 * Must be created outside the transaction due to it breaking the transaction.
39 public $membershipTypeID;
44 public function setUp() {
46 $this->membershipTypeID
= $this->membershipTypeCreate(['name' => 'General']);
47 $this->useTransaction(TRUE);
50 'name' => 'API_Test_Job',
51 'description' => 'A long description written by hand in cursive',
52 'run_frequency' => 'Daily',
53 'api_entity' => 'ApiTestEntity',
54 'api_action' => 'apitestaction',
55 'parameters' => 'Semi-formal explanation of runtime job parameters',
63 * @throws \CRM_Core_Exception
65 public function tearDown() {
67 // The membershipType create breaks transactions so this extra cleanup is needed.
68 $this->membershipTypeDelete(['id' => $this->membershipTypeID
]);
69 $this->cleanUpSetUpIDs();
70 $this->quickCleanup(['civicrm_contact', 'civicrm_address', 'civicrm_email', 'civicrm_website', 'civicrm_phone'], TRUE);
77 public function testCreateWithoutName() {
81 $this->callAPIFailure('job', 'create', $params,
82 'Mandatory key(s) missing from params array: run_frequency, name, api_entity, api_action'
87 * Create job with an invalid "run_frequency" value.
89 public function testCreateWithInvalidFrequency() {
92 'name' => 'API_Test_Job',
93 'description' => 'A long description written by hand in cursive',
94 'run_frequency' => 'Fortnightly',
95 'api_entity' => 'ApiTestEntity',
96 'api_action' => 'apitestaction',
97 'parameters' => 'Semi-formal explanation of runtime job parameters',
100 $this->callAPIFailure('job', 'create', $params);
106 public function testCreate() {
107 $result = $this->callAPIAndDocument('job', 'create', $this->_params
, __FUNCTION__
, __FILE__
);
108 $this->assertNotNull($result['values'][0]['id']);
110 // mutate $params to match expected return value
111 unset($this->_params
['sequential']);
112 //assertDBState compares expected values in $result to actual values in the DB
113 $this->assertDBState('CRM_Core_DAO_Job', $result['id'], $this->_params
);
119 * @throws \CRM_Core_Exception
121 public function testClone() {
122 $createResult = $this->callAPISuccess('job', 'create', $this->_params
);
123 $params = ['id' => $createResult['id']];
124 $cloneResult = $this->callAPIAndDocument('job', 'clone', $params, __FUNCTION__
, __FILE__
);
125 $clonedJob = $cloneResult['values'][$cloneResult['id']];
126 $this->assertEquals($this->_params
['name'] . ' - Copy', $clonedJob['name']);
127 $this->assertEquals($this->_params
['description'], $clonedJob['description']);
128 $this->assertEquals($this->_params
['parameters'], $clonedJob['parameters']);
129 $this->assertEquals($this->_params
['is_active'], $clonedJob['is_active']);
130 $this->assertArrayNotHasKey('last_run', $clonedJob);
131 $this->assertArrayNotHasKey('scheduled_run_date', $clonedJob);
135 * Check if required fields are not passed.
137 public function testDeleteWithoutRequired() {
139 'name' => 'API_Test_PP',
140 'title' => 'API Test Payment Processor',
141 'class_name' => 'CRM_Core_Payment_APITest',
144 $result = $this->callAPIFailure('job', 'delete', $params);
145 $this->assertEquals($result['error_message'], 'Mandatory key(s) missing from params array: id');
149 * Check with incorrect required fields.
151 public function testDeleteWithIncorrectData() {
155 $this->callAPIFailure('job', 'delete', $params);
161 public function testDelete() {
162 $createResult = $this->callAPISuccess('job', 'create', $this->_params
);
163 $params = ['id' => $createResult['id']];
164 $this->callAPIAndDocument('job', 'delete', $params, __FUNCTION__
, __FILE__
);
165 $this->assertAPIDeleted($this->_entity
, $createResult['id']);
169 * Test greeting update job.
171 * Note that this test is about tesing the metadata / calling of the function & doesn't test the success of the called function
173 * @throws \CRM_Core_Exception
175 public function testCallUpdateGreetingSuccess() {
176 $this->callAPISuccess($this->_entity
, 'update_greeting', [
177 'gt' => 'postal_greeting',
178 'ct' => 'Individual',
183 * Test greeting update handles comma separated params.
185 * @throws \CRM_Core_Exception
187 public function testCallUpdateGreetingCommaSeparatedParamsSuccess() {
188 $gt = 'postal_greeting,email_greeting,addressee';
189 $ct = 'Individual,Household';
190 $this->callAPISuccess($this->_entity
, 'update_greeting', ['gt' => $gt, 'ct' => $ct]);
194 * Test the call reminder success sends more than 25 reminders & is not incorrectly limited.
196 * Note that this particular test sends the reminders to the additional recipients only
197 * as no real reminder person is configured
199 * Also note that this is testing a 'job' api so is in this class rather than scheduled_reminder - which
200 * seems a cleaner place to build up a collection of scheduled reminder testing functions. However, it seems
201 * that the api itself would need to be moved to the scheduled_reminder fn to do that with the job wrapper being respected for legacy functions
203 * @throws \CRM_Core_Exception
205 public function testCallSendReminderSuccessMoreThanDefaultLimit() {
206 $membershipTypeID = $this->membershipTypeCreate();
207 $this->membershipStatusCreate();
209 for ($i = 1; $i <= $createTotal; $i++
) {
210 $contactID = $this->individualCreate();
211 $groupID = $this->groupCreate(['name' => $i, 'title' => $i]);
212 $this->callAPISuccess('action_schedule', 'create', [
213 'title' => " job $i",
214 'subject' => "job $i",
215 'entity_value' => $membershipTypeID,
217 'start_action_date' => 'membership_join_date',
218 'start_action_offset' => 0,
219 'start_action_condition' => 'before',
220 'start_action_unit' => 'hour',
221 'group_id' => $groupID,
224 $this->callAPISuccess('group_contact', 'create', [
225 'contact_id' => $contactID,
227 'group_id' => $groupID,
230 $this->callAPISuccess('job', 'send_reminder', []);
231 $successfulCronCount = CRM_Core_DAO
::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
232 $this->assertEquals($successfulCronCount, $createTotal);
236 * Test scheduled reminders respect limit to (since above identified addition_to handling issue).
238 * We create 3 contacts - 1 is in our group, 1 has our membership & the chosen one has both
239 * & check that only the chosen one got the reminder
241 * @throws \CRM_Core_Exception
242 * @throws \CiviCRM_API3_Exception
244 public function testCallSendReminderLimitToSMS() {
245 list($membershipTypeID, $groupID, $theChosenOneID, $provider) = $this->setUpMembershipSMSReminders();
246 $this->callAPISuccess('action_schedule', 'create', [
247 'title' => ' remind all Texans',
248 'subject' => 'drawling renewal',
249 'entity_value' => $membershipTypeID,
251 'start_action_date' => 'membership_start_date',
252 'start_action_offset' => 1,
253 'start_action_condition' => 'before',
254 'start_action_unit' => 'day',
255 'group_id' => $groupID,
257 'sms_provider_id' => $provider['id'],
258 'mode' => 'User_Preference',
260 $this->callAPISuccess('job', 'send_reminder', []);
261 $successfulCronCount = CRM_Core_DAO
::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
262 $this->assertEquals($successfulCronCount, 1);
263 $sentToID = CRM_Core_DAO
::singleValueQuery("SELECT contact_id FROM civicrm_action_log");
264 $this->assertEquals($sentToID, $theChosenOneID);
265 $this->assertEquals(0, CRM_Core_DAO
::singleValueQuery("SELECT is_error FROM civicrm_action_log"));
266 $this->setupForSmsTests(TRUE);
270 * Test disabling expired relationships.
272 * @throws \CRM_Core_Exception
274 public function testCallDisableExpiredRelationships() {
275 $individualID = $this->individualCreate();
276 $orgID = $this->organizationCreate();
277 CRM_Utils_Hook_UnitTests
::singleton()->setHook('civicrm_pre', [$this, 'hookPreRelationship']);
278 $relationshipTypeID = $this->callAPISuccess('relationship_type', 'getvalue', [
280 'name_a_b' => 'Employee of',
282 $result = $this->callAPISuccess('relationship', 'create', [
283 'relationship_type_id' => $relationshipTypeID,
284 'contact_id_a' => $individualID,
285 'contact_id_b' => $orgID,
287 'end_date' => 'yesterday',
289 $relationshipID = $result['id'];
290 $this->assertEquals('Hooked', $result['values'][$relationshipID]['description']);
291 $this->callAPISuccess($this->_entity
, 'disable_expired_relationships', []);
292 $result = $this->callAPISuccess('relationship', 'get', []);
293 $this->assertEquals('Go Go you good thing', $result['values'][$relationshipID]['description']);
294 $this->contactDelete($individualID);
295 $this->contactDelete($orgID);
299 * Event templates should not send reminders to additional contacts.
301 * @throws \CRM_Core_Exception
303 public function testTemplateRemindAddlContacts() {
304 $contactId = $this->individualCreate();
305 $groupId = $this->groupCreate(['name' => 'Additional Contacts', 'title' => 'Additional Contacts']);
306 $this->callAPISuccess('GroupContact', 'create', [
307 'contact_id' => $contactId,
308 'group_id' => $groupId,
310 $event = $this->eventCreate(['is_template' => 1, 'template_title' => "I'm a template", 'title' => NULL]);
311 $eventId = $event['id'];
313 $this->callAPISuccess('action_schedule', 'create', [
314 'title' => 'Do not send me',
315 'subject' => 'I am a reminder attached to a template.',
316 'entity_value' => $eventId,
318 'start_action_date' => 'start_date',
319 'start_action_offset' => 1,
320 'start_action_condition' => 'before',
321 'start_action_unit' => 'day',
322 'group_id' => $groupId,
327 $this->callAPISuccess('job', 'send_reminder', []);
328 $successfulCronCount = CRM_Core_DAO
::singleValueQuery('SELECT count(*) FROM civicrm_action_log');
329 $this->assertEquals(0, $successfulCronCount);
333 * Test scheduled reminders respect limit to (since above identified addition_to handling issue).
335 * We create 3 contacts - 1 is in our group, 1 has our membership & the chosen one has both
336 * & check that only the chosen one got the reminder
338 * Also check no hard fail on cron job with running a reminder that has a deleted SMS provider
340 * @throws \CRM_Core_Exception
341 * @throws \CiviCRM_API3_Exception
343 public function testCallSendReminderLimitToSMSWithDeletedProvider() {
344 list($membershipTypeID, $groupID, $theChosenOneID, $provider) = $this->setUpMembershipSMSReminders();
345 $this->callAPISuccess('action_schedule', 'create', [
346 'title' => ' remind all Texans',
347 'subject' => 'drawling renewal',
348 'entity_value' => $membershipTypeID,
350 'start_action_date' => 'membership_start_date',
351 'start_action_offset' => 1,
352 'start_action_condition' => 'before',
353 'start_action_unit' => 'day',
354 'group_id' => $groupID,
356 'sms_provider_id' => $provider['id'],
359 $this->callAPISuccess('SmsProvider', 'delete', ['id' => $provider['id']]);
360 $this->callAPISuccess('job', 'send_reminder', []);
361 $cronCount = CRM_Core_DAO
::singleValueQuery("SELECT count(*) FROM civicrm_action_log");
362 $this->assertEquals($cronCount, 1);
363 $sentToID = CRM_Core_DAO
::singleValueQuery("SELECT contact_id FROM civicrm_action_log");
364 $this->assertEquals($sentToID, $theChosenOneID);
365 $cronlog = CRM_Core_DAO
::executeQuery("SELECT * FROM civicrm_action_log")->fetchAll()[0];
366 $this->assertEquals(1, $cronlog['is_error']);
367 $this->assertEquals('SMS reminder cannot be sent because the SMS provider has been deleted.', $cronlog['message']);
368 $this->setupForSmsTests(TRUE);
372 * Test the batch merge function.
374 * We are just checking it returns without error here.
376 * @throws \CRM_Core_Exception
378 public function testBatchMerge() {
379 $this->callAPISuccess('Job', 'process_batch_merge', []);
383 * Test the batch merge function actually works!
385 * @dataProvider getMergeSets
389 * @throws \CRM_Core_Exception
391 public function testBatchMergeWorks($dataSet) {
392 foreach ($dataSet['contacts'] as $params) {
393 $this->callAPISuccess('Contact', 'create', $params);
396 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => $dataSet['mode']]);
397 $this->assertCount($dataSet['skipped'], $result['values']['skipped'], 'Failed to skip the right number:' . $dataSet['skipped']);
398 $this->assertCount($dataSet['merged'], $result['values']['merged']);
399 $result = $this->callAPISuccess('Contact', 'get', [
400 'contact_sub_type' => 'Student',
402 'is_deceased' => ['IN' => [0, 1]],
403 'options' => ['sort' => 'id ASC'],
405 $this->assertEquals(count($dataSet['expected']), $result['count']);
406 foreach ($dataSet['expected'] as $index => $contact) {
407 foreach ($contact as $key => $value) {
408 if ($key === 'gender_id') {
411 $this->assertEquals($value, $result['values'][$index][$key]);
417 * Check that the merge carries across various related entities.
419 * Note the group combinations & expected results:
421 * @throws \CRM_Core_Exception
423 public function testBatchMergeWithAssets() {
424 $contactID = $this->individualCreate();
425 $contact2ID = $this->individualCreate();
426 $this->contributionCreate(['contact_id' => $contactID]);
427 $this->contributionCreate(['contact_id' => $contact2ID, 'invoice_id' => '2', 'trxn_id' => 2]);
428 $this->contactMembershipCreate(['contact_id' => $contactID]);
429 $this->contactMembershipCreate(['contact_id' => $contact2ID]);
430 $this->activityCreate(['source_contact_id' => $contactID, 'target_contact_id' => $contactID, 'assignee_contact_id' => $contactID]);
431 $this->activityCreate(['source_contact_id' => $contact2ID, 'target_contact_id' => $contact2ID, 'assignee_contact_id' => $contact2ID]);
432 $this->tagCreate(['name' => 'Tall']);
433 $this->tagCreate(['name' => 'Short']);
434 $this->entityTagAdd(['contact_id' => $contactID, 'tag_id' => 'Tall']);
435 $this->entityTagAdd(['contact_id' => $contact2ID, 'tag_id' => 'Short']);
436 $this->entityTagAdd(['contact_id' => $contact2ID, 'tag_id' => 'Tall']);
437 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
438 $this->assertEquals(0, count($result['values']['skipped']));
439 $this->assertEquals(1, count($result['values']['merged']));
440 $this->callAPISuccessGetCount('Contribution', ['contact_id' => $contactID], 2);
441 $this->callAPISuccessGetCount('Contribution', ['contact_id' => $contact2ID], 0);
442 $this->callAPISuccessGetCount('FinancialItem', ['contact_id' => $contactID], 2);
443 $this->callAPISuccessGetCount('FinancialItem', ['contact_id' => $contact2ID], 0);
444 $this->callAPISuccessGetCount('Membership', ['contact_id' => $contactID], 2);
445 $this->callAPISuccessGetCount('Membership', ['contact_id' => $contact2ID], 0);
446 $this->callAPISuccessGetCount('EntityTag', ['contact_id' => $contactID], 2);
447 $this->callAPISuccessGetCount('EntityTag', ['contact_id' => $contact2ID], 0);
448 // 14 activities is one for each contribution (2), two (source + target) for each membership (+(2x2) = 6)
449 // 3 for each of the added activities as there are 3 roles (+6 = 12
450 // 2 for the (source & target) contact merged activity (+2 = 14)
451 $this->callAPISuccessGetCount('ActivityContact', ['contact_id' => $contactID], 14);
452 // 2 for the connection to the deleted by merge activity (source & target)
453 $this->callAPISuccessGetCount('ActivityContact', ['contact_id' => $contact2ID], 2);
457 * Check that the merge carries across various related entities.
459 * Note the group combinations 'expected' results:
461 * Group 0 Added null Added
462 * Group 1 Added Added Added
463 * Group 2 Added Removed **** Added
464 * Group 3 Removed null **** null
465 * Group 4 Removed Added **** Added
466 * Group 5 Removed Removed **** null
467 * Group 6 null Added Added
468 * Group 7 null Removed **** null
470 * The ones with **** are the ones where I think a case could be made to change the behaviour.
472 * @throws \CRM_Core_Exception
474 public function testBatchMergeMergesGroups() {
475 $contactID = $this->individualCreate();
476 $contact2ID = $this->individualCreate();
478 for ($i = 0; $i < 8; $i++
) {
479 $groups[] = $this->groupCreate([
480 'name' => 'mergeGroup' . $i,
481 'title' => 'merge group' . $i,
485 $this->callAPISuccess('GroupContact', 'create', [
486 'contact_id' => $contactID,
487 'group_id' => $groups[0],
489 $this->callAPISuccess('GroupContact', 'create', [
490 'contact_id' => $contactID,
491 'group_id' => $groups[1],
493 $this->callAPISuccess('GroupContact', 'create', [
494 'contact_id' => $contactID,
495 'group_id' => $groups[2],
497 $this->callAPISuccess('GroupContact', 'create', [
498 'contact_id' => $contactID,
499 'group_id' => $groups[3],
500 'status' => 'Removed',
502 $this->callAPISuccess('GroupContact', 'create', [
503 'contact_id' => $contactID,
504 'group_id' => $groups[4],
505 'status' => 'Removed',
507 $this->callAPISuccess('GroupContact', 'create', [
508 'contact_id' => $contactID,
509 'group_id' => $groups[5],
510 'status' => 'Removed',
512 $this->callAPISuccess('GroupContact', 'create', [
513 'contact_id' => $contact2ID,
514 'group_id' => $groups[1],
516 $this->callAPISuccess('GroupContact', 'create', [
517 'contact_id' => $contact2ID,
518 'group_id' => $groups[2],
519 'status' => 'Removed',
521 $this->callAPISuccess('GroupContact', 'create', [
522 'contact_id' => $contact2ID,
523 'group_id' => $groups[4],
525 $this->callAPISuccess('GroupContact', 'create', [
526 'contact_id' => $contact2ID,
527 'group_id' => $groups[5],
528 'status' => 'Removed',
530 $this->callAPISuccess('GroupContact', 'create', [
531 'contact_id' => $contact2ID,
532 'group_id' => $groups[6],
534 $this->callAPISuccess('GroupContact', 'create', [
535 'contact_id' => $contact2ID,
536 'group_id' => $groups[7],
537 'status' => 'Removed',
539 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
540 $this->assertCount(0, $result['values']['skipped']);
541 $this->assertCount(1, $result['values']['merged']);
542 $groupResult = $this->callAPISuccess('GroupContact', 'get', []);
543 $this->assertEquals(5, $groupResult['count']);
551 foreach ($groupResult['values'] as $groupValues) {
552 $this->assertEquals($contactID, $groupValues['contact_id']);
553 $this->assertEquals('Added', $groupValues['status']);
554 $this->assertTrue(in_array($groupValues['group_id'], $expectedGroups));
560 * Test the decisions made for addresses when merging.
562 * @dataProvider getMergeLocationData
565 * (the ones with **** could be disputed as whether it is the best outcome).
566 * 'matching_primary' - Primary matches, including location_type_id. One contact has an additional address.
567 * - result - primary is the shared one. Additional address is retained.
568 * 'matching_primary_reverse' - Primary matches, including location_type_id. Keep both. (opposite order)
569 * - result - primary is the shared one. Additional address is retained.
570 * 'only_one_has_address' - Only one contact has addresses (retain)
571 * - the (only) address is retained
572 * 'only_one_has_address_reverse'
573 * - the (only) address is retained
574 * 'different_primaries_with_different_location_type' Primaries are different but do not clash due to diff type
575 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
576 * 'different_primaries_with_different_location_type_reverse' Primaries are different but do not clash due to diff type
577 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
578 * 'different_primaries_location_match_only_one_address' per previous but a second address matches the primary but is not primary
579 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
580 * 'different_primaries_location_match_only_one_address_reverse' per previous but a second address matches the primary but is not primary
581 * - result - both addresses kept. The one from the kept (lowest ID) contact is primary
582 * 'same_primaries_different_location' Primary addresses are the same but have different location type IDs
583 * - result primary kept with the lowest ID. Other address retained too (to preserve location type info).
584 * 'same_primaries_different_location_reverse' Primary addresses are the same but have different location type IDs
585 * - result primary kept with the lowest ID. Other address retained too (to preserve location type info).
587 * @param array $dataSet
589 * @throws \CRM_Core_Exception
591 public function testBatchMergesAddresses($dataSet) {
592 $contactID1 = $this->individualCreate();
593 $contactID2 = $this->individualCreate();
594 foreach ($dataSet['contact_1'] as $address) {
595 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID1], $address));
597 foreach ($dataSet['contact_2'] as $address) {
598 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID2], $address));
601 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
602 $this->assertCount(1, $result['values']['merged']);
603 $addresses = $this->callAPISuccess($dataSet['entity'], 'get', ['contact_id' => $contactID1, 'sequential' => 1]);
604 $this->assertEquals(count($dataSet['expected']), $addresses['count'], 'Did not get the expected result for ' . $dataSet['entity'] . (!empty($dataSet['description']) ?
" on dataset {$dataSet['description']}" : ''));
605 $locationTypes = $this->callAPISuccess($dataSet['entity'], 'getoptions', ['field' => 'location_type_id']);
606 foreach ($dataSet['expected'] as $index => $expectedAddress) {
607 foreach ($expectedAddress as $key => $value) {
608 if ($key === 'location_type_id') {
609 $this->assertEquals($locationTypes['values'][$addresses['values'][$index][$key]], $value);
612 $this->assertEquals($addresses['values'][$index][$key], $value, "mismatch on $key" . (!empty($dataSet['description']) ?
" on dataset {$dataSet['description']}" : ''));
619 * Test altering the address decision by hook.
621 * @dataProvider getMergeLocationData
623 * @param array $dataSet
625 * @throws \CRM_Core_Exception
627 public function testBatchMergesAddressesHook($dataSet) {
628 $contactID1 = $this->individualCreate();
629 $contactID2 = $this->individualCreate();
630 $this->contributionCreate(['contact_id' => $contactID1, 'receive_date' => '2010-01-01', 'invoice_id' => 1, 'trxn_id' => 1]);
631 $this->contributionCreate(['contact_id' => $contactID2, 'receive_date' => '2012-01-01', 'invoice_id' => 2, 'trxn_id' => 2]);
632 foreach ($dataSet['contact_1'] as $address) {
633 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID1], $address));
635 foreach ($dataSet['contact_2'] as $address) {
636 $this->callAPISuccess($dataSet['entity'], 'create', array_merge(['contact_id' => $contactID2], $address));
638 $this->hookClass
->setHook('civicrm_alterLocationMergeData', [$this, 'hookMostRecentDonor']);
640 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'safe']);
641 $this->assertCount(1, $result['values']['merged']);
642 $addresses = $this->callAPISuccess($dataSet['entity'], 'get', ['contact_id' => $contactID1, 'sequential' => 1]);
643 $this->assertEquals(count($dataSet['expected_hook']), $addresses['count']);
644 $locationTypes = $this->callAPISuccess($dataSet['entity'], 'getoptions', ['field' => 'location_type_id']);
645 foreach ($dataSet['expected_hook'] as $index => $expectedAddress) {
646 foreach ($expectedAddress as $key => $value) {
647 if ($key === 'location_type_id') {
648 $this->assertEquals($locationTypes['values'][$addresses['values'][$index][$key]], $value);
651 $this->assertEquals($value, $addresses['values'][$index][$key], $dataSet['entity'] . ': Unexpected value for ' . $key . (!empty($dataSet['description']) ?
" on dataset {$dataSet['description']}" : ''));
658 * Test the organization will not be matched to an individual.
660 * @throws \CRM_Core_Exception
662 public function testBatchMergeWillNotMergeOrganizationToIndividual() {
663 $individual = $this->callAPISuccess('Contact', 'create', [
664 'contact_type' => 'Individual',
665 'organization_name' => 'Anon',
666 'email' => 'anonymous@hacker.com',
668 $organization = $this->callAPISuccess('Contact', 'create', [
669 'contact_type' => 'Organization',
670 'organization_name' => 'Anon',
671 'email' => 'anonymous@hacker.com',
673 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['mode' => 'aggressive']);
674 $this->assertCount(0, $result['values']['skipped']);
675 $this->assertCount(0, $result['values']['merged']);
676 $this->callAPISuccessGetSingle('Contact', ['id' => $individual['id']]);
677 $this->callAPISuccessGetSingle('Contact', ['id' => $organization['id']]);
682 * Test hook allowing modification of the data calculated for merging locations.
684 * We are testing a nuanced real life situation where the address data of the
685 * most recent donor gets priority - resulting in the primary address being set
686 * to the primary address of the most recent donor and address data on a per
687 * location type basis also being set to the most recent donor. Hook also excludes
688 * a fully matching address with a different location.
690 * This has been added to the test suite to ensure the code supports more this
691 * type of intervention.
693 * @param array $blocksDAO
694 * Array of location DAO to be saved. These are arrays in 2 keys 'update' & 'delete'.
696 * Contact_id of the contact that survives the merge.
697 * @param int $otherId
698 * Contact_id of the contact that will be absorbed and deleted.
699 * @param array $migrationInfo
700 * Calculated migration info, informational only.
703 * @throws \CRM_Core_Exception
705 public function hookMostRecentDonor(&$blocksDAO, $mainId, $otherId, $migrationInfo) {
707 $lastDonorID = $this->callAPISuccessGetValue('Contribution', [
708 'return' => 'contact_id',
709 'contact_id' => ['IN' => [$mainId, $otherId]],
710 'options' => ['sort' => 'receive_date DESC', 'limit' => 1],
712 // Since the last donor is not the main ID we are prioritising info from the last donor.
713 // In the test this should always be true - but keep the check in case
714 // something changes that we need to detect.
715 if ($lastDonorID != $mainId) {
716 foreach ($migrationInfo['other_details']['location_blocks'] as $blockType => $blocks) {
717 foreach ($blocks as $block) {
718 if ($block['is_primary']) {
719 $primaryAddressID = $block['id'];
720 if (!empty($migrationInfo['main_details']['location_blocks'][$blockType])) {
721 foreach ($migrationInfo['main_details']['location_blocks'][$blockType] as $mainBlock) {
722 if (empty($blocksDAO[$blockType]['update'][$block['id']]) && $mainBlock['location_type_id'] == $block['location_type_id']) {
723 // This was an address match - we just need to check the is_primary
724 // is true on the matching kept address.
725 $primaryAddressID = $mainBlock['id'];
726 $blocksDAO[$blockType]['update'][$primaryAddressID] = _civicrm_api3_load_DAO($blockType);
727 $blocksDAO[$blockType]['update'][$primaryAddressID]->id
= $primaryAddressID;
729 $mainLocationTypeID = $mainBlock['location_type_id'];
730 // We also want to be more ruthless about removing matching addresses.
731 unset($mainBlock['location_type_id']);
732 if (CRM_Dedupe_Merger
::locationIsSame($block, $mainBlock)
733 && (!isset($blocksDAO[$blockType]['update']) ||
!isset($blocksDAO[$blockType]['update'][$mainBlock['id']]))
734 && (!isset($blocksDAO[$blockType]['delete']) ||
!isset($blocksDAO[$blockType]['delete'][$mainBlock['id']]))
736 $blocksDAO[$blockType]['delete'][$mainBlock['id']] = _civicrm_api3_load_DAO($blockType);
737 $blocksDAO[$blockType]['delete'][$mainBlock['id']]->id
= $mainBlock['id'];
739 // Arguably the right way to handle this is just to set is_primary for the primary
740 // and for the merge fn to call something like BAO::add & hooks to work etc.
741 // if that happens though this should keep working...
742 elseif ($mainBlock['is_primary'] && $mainLocationTypeID != $block['location_type_id']) {
743 $blocksDAO['address']['update'][$mainBlock['id']] = _civicrm_api3_load_DAO($blockType);
744 $blocksDAO['address']['update'][$mainBlock['id']]->is_primary
= 0;
745 $blocksDAO['address']['update'][$mainBlock['id']]->id
= $mainBlock['id'];
749 $blocksDAO[$blockType]['update'][$primaryAddressID]->is_primary
= 1;
758 * Get address combinations for the merge test.
762 public function getMergeLocationData() {
763 $address1 = ['street_address' => 'Buckingham Palace', 'city' => 'London'];
764 $address2 = ['street_address' => 'The Doghouse', 'supplemental_address_1' => 'under the blanket'];
765 $data = $this->getMergeLocations($address1, $address2, 'Address');
766 $data = array_merge($data, $this->getMergeLocations(['phone' => '12345', 'phone_type_id' => 1], ['phone' => '678910', 'phone_type_id' => 1], 'Phone'));
767 $data = array_merge($data, $this->getMergeLocations(['phone' => '12345'], ['phone' => '678910'], 'Phone'));
768 $data = array_merge($data, $this->getMergeLocations(['email' => 'mini@me.com'], ['email' => 'mini@me.org'], 'Email', [
770 'email' => 'anthony_anderson@civicrm.org',
771 'location_type_id' => 'Home',
779 * Test weird characters don't mess with merge & cause a fatal.
781 * @throws \CRM_Core_Exception
783 public function testNoErrorOnOdd() {
784 $this->individualCreate();
785 $this->individualCreate(['first_name' => 'Gerrit%0a%2e%0a']);
786 $this->callAPISuccess('Job', 'process_batch_merge', []);
788 $this->individualCreate();
789 $this->individualCreate(['first_name' => '[foo\\bar\'baz']);
790 $this->callAPISuccess('Job', 'process_batch_merge', []);
791 $this->callAPISuccessGetSingle('Contact', ['first_name' => '[foo\\bar\'baz']);
795 * Test the batch merge does not create duplicate emails.
797 * Test CRM-18546, a 4.7 regression whereby a merged contact gets duplicate emails.
799 * @throws \CRM_Core_Exception
801 public function testBatchMergeEmailHandling() {
802 for ($x = 0; $x <= 4; $x++
) {
803 $id = $this->individualCreate(['email' => 'batman@gotham.met']);
805 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
806 $this->assertCount(4, $result['values']['merged']);
807 $this->callAPISuccessGetCount('Contact', ['email' => 'batman@gotham.met'], 1);
808 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
809 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 1]);
810 $this->callAPISuccessGetCount('Email', [
811 'email' => 'batman@gotham.met',
812 'contact_id' => ['IN' => array_keys($contacts['values'])],
814 $this->callAPISuccessGetCount('Email', [
815 'email' => 'batman@gotham.met',
816 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
821 * Test the batch merge respects email "on hold".
823 * Test CRM-19148, Batch merge - Email on hold data lost when there is a conflict.
825 * @dataProvider getOnHoldSets
827 * @param bool $onHold1
828 * @param bool $onHold2
830 * @param string $conflictText
832 * @throws \CRM_Core_Exception
834 public function testBatchMergeEmailOnHold($onHold1, $onHold2, $merge, $conflictText) {
835 $this->individualCreate([
836 'api.email.create' => [
837 'email' => 'batman@gotham.met',
838 'location_type_id' => 'Work',
840 'on_hold' => $onHold1,
843 $this->individualCreate([
844 'api.email.create' => [
845 'email' => 'batman@gotham.met',
846 'location_type_id' => 'Work',
848 'on_hold' => $onHold2,
851 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
852 $this->assertCount($merge, $result['values']['merged']);
854 $defaultRuleGroupID = $this->callAPISuccessGetValue('RuleGroup', [
855 'contact_type' => 'Individual',
856 'used' => 'Unsupervised',
858 'options' => ['limit' => 1],
861 $duplicates = $this->callAPISuccess('Dedupe', 'getduplicates', ['rule_group_id' => $defaultRuleGroupID]);
862 $this->assertEquals($conflictText, $duplicates['values'][0]['conflicts']);
867 * Data provider for testBatchMergeEmailOnHold: combinations of on_hold & expected outcomes.
869 public function getOnHoldSets() {
870 // Each row specifies: contact 1 on_hold, contact 2 on_hold, merge? (0 or 1),
873 [0, 1, 0, "Email 2 (Work): 'batman@gotham.met' vs. 'batman@gotham.met\n(On Hold)'"],
874 [1, 0, 0, "Email 2 (Work): 'batman@gotham.met\n(On Hold)' vs. 'batman@gotham.met'"],
880 * Test the batch merge does not fatal on an empty rule.
882 * @dataProvider getRuleSets
884 * @param string $contactType
885 * @param string $used
886 * @param string $name
887 * @param bool $isReserved
888 * @param int $threshold
890 * @throws \CRM_Core_Exception
892 public function testBatchMergeEmptyRule($contactType, $used, $name, $isReserved, $threshold) {
893 $ruleGroup = $this->callAPISuccess('RuleGroup', 'create', [
894 'contact_type' => $contactType,
895 'threshold' => $threshold,
898 'is_reserved' => $isReserved,
900 $this->callAPISuccess('Job', 'process_batch_merge', ['rule_group_id' => $ruleGroup['id']]);
901 $this->callAPISuccess('RuleGroup', 'delete', ['id' => $ruleGroup['id']]);
905 * Get the various rule combinations.
907 public function getRuleSets() {
908 $contactTypes = ['Individual', 'Organization', 'Household'];
909 $useds = ['Unsupervised', 'General', 'Supervised'];
911 foreach ($contactTypes as $contactType) {
912 foreach ($useds as $used) {
913 $ruleGroups[] = [$contactType, $used, 'Bob', FALSE, 0];
914 $ruleGroups[] = [$contactType, $used, 'Bob', FALSE, 10];
915 $ruleGroups[] = [$contactType, $used, 'Bob', TRUE, 10];
916 $ruleGroups[] = [$contactType, $used, $contactType . $used, FALSE, 10];
917 $ruleGroups[] = [$contactType, $used, $contactType . $used, TRUE, 10];
924 * Test the batch merge does not create duplicate emails.
926 * Test CRM-18546, a 4.7 regression whereby a merged contact gets duplicate emails.
928 * @throws \CRM_Core_Exception
930 public function testBatchMergeMatchingAddress() {
931 for ($x = 0; $x <= 2; $x++
) {
932 $this->individualCreate([
933 'api.address.create' => [
934 'location_type_id' => 'Home',
935 'street_address' => 'Appt 115, The Batcave',
937 'postal_code' => 'Nananananana',
941 // Different location type, still merge, identical.
942 $this->individualCreate([
943 'api.address.create' => [
944 'location_type_id' => 'Main',
945 'street_address' => 'Appt 115, The Batcave',
947 'postal_code' => 'Nananananana',
951 $this->individualCreate([
952 'api.address.create' => [
953 'location_type_id' => 'Home',
954 'street_address' => 'Appt 115, The Batcave',
956 'postal_code' => 'Batman',
960 $result = $this->callAPISuccess('Job', 'process_batch_merge', []);
961 $this->assertEquals(3, count($result['values']['merged']));
962 $this->assertEquals(1, count($result['values']['skipped']));
963 $this->callAPISuccessGetCount('Contact', ['street_address' => 'Appt 115, The Batcave'], 2);
964 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
965 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 1]);
966 $this->callAPISuccessGetCount('Address', [
967 'street_address' => 'Appt 115, The Batcave',
968 'contact_id' => ['IN' => array_keys($contacts['values'])],
971 $this->callAPISuccessGetCount('Address', [
972 'street_address' => 'Appt 115, The Batcave',
973 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
978 * Test the batch merge by id range.
980 * We have 2 sets of 5 matches & set the merge only to merge the lower set.
982 * @throws \CRM_Core_Exception
984 public function testBatchMergeIDRange() {
985 for ($x = 0; $x <= 4; $x++
) {
986 $id = $this->individualCreate(['email' => 'batman@gotham.met']);
988 for ($x = 0; $x <= 4; $x++
) {
989 $this->individualCreate(['email' => 'robin@gotham.met']);
991 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['criteria' => ['contact' => ['id' => ['<' => $id]]]]);
992 $this->assertEquals(4, count($result['values']['merged']));
993 $this->callAPISuccessGetCount('Contact', ['email' => 'batman@gotham.met'], 1);
994 $this->callAPISuccessGetCount('Contact', ['email' => 'robin@gotham.met'], 5);
995 $contacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
996 $deletedContacts = $this->callAPISuccess('Contact', 'get', ['is_deleted' => 0]);
997 $this->callAPISuccessGetCount('Email', [
998 'email' => 'batman@gotham.met',
999 'contact_id' => ['IN' => array_keys($contacts['values'])],
1001 $this->callAPISuccessGetCount('Email', [
1002 'email' => 'batman@gotham.met',
1003 'contact_id' => ['IN' => array_keys($deletedContacts['values'])],
1005 $this->callAPISuccessGetCount('Email', [
1006 'email' => 'robin@gotham.met',
1007 'contact_id' => ['IN' => array_keys($contacts['values'])],
1013 * Test the batch merge copes with view only custom data field.
1015 * @throws \CRM_Core_Exception
1017 public function testBatchMergeCustomDataViewOnlyField() {
1018 CRM_Core_Config
::singleton()->userPermissionClass
->permissions
= ['access CiviCRM', 'edit my contact'];
1019 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1020 $this->individualCreate($mouseParams);
1022 $customGroup = $this->customGroupCreate();
1023 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'is_view' => 1]);
1024 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 'blah']));
1026 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1027 $this->assertEquals(1, count($result['values']['merged']));
1028 $mouseParams['return'] = 'custom_' . $customField['id'];
1029 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1030 $this->assertEquals('blah', $mouse['custom_' . $customField['id']]);
1032 $this->customFieldDelete($customField['id']);
1033 $this->customGroupDelete($customGroup['id']);
1037 * Test the batch merge retains 0 as a valid custom field value.
1039 * Note that we set 0 on 2 fields with one on each contact to ensure that
1040 * both merged & mergee fields are respected.
1042 * @throws \CRM_Core_Exception
1044 public function testBatchMergeCustomDataZeroValueField() {
1045 $customGroup = $this->customGroupCreate();
1046 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'default_value' => NULL]);
1048 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1049 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => '']));
1050 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 0]));
1052 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1053 $this->assertCount(1, $result['values']['merged']);
1054 $mouseParams['return'] = 'custom_' . $customField['id'];
1055 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1056 $this->assertEquals(0, $mouse['custom_' . $customField['id']]);
1058 $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => NULL]));
1059 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1060 $this->assertEquals(1, count($result['values']['merged']));
1061 $mouseParams['return'] = 'custom_' . $customField['id'];
1062 $mouse = $this->callAPISuccess('Contact', 'getsingle', $mouseParams);
1063 $this->assertEquals(0, $mouse['custom_' . $customField['id']]);
1065 $this->customFieldDelete($customField['id']);
1066 $this->customGroupDelete($customGroup['id']);
1070 * Test the batch merge treats 0 vs 1 as a conflict.
1072 * @throws \CRM_Core_Exception
1074 public function testBatchMergeCustomDataZeroValueFieldWithConflict() {
1075 $customGroup = $this->customGroupCreate();
1076 $customField = $this->customFieldCreate(['custom_group_id' => $customGroup['id'], 'default_value' => NULL]);
1078 $mouseParams = ['first_name' => 'Mickey', 'last_name' => 'Mouse', 'email' => 'tha_mouse@mouse.com'];
1079 $mouse1 = $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 0]));
1080 $mouse2 = $this->individualCreate(array_merge($mouseParams, ['custom_' . $customField['id'] => 1]));
1082 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1083 $this->assertCount(0, $result['values']['merged']);
1085 // Reverse which mouse has the zero to test we still get a conflict.
1086 $this->individualCreate(array_merge($mouseParams, ['id' => $mouse1, 'custom_' . $customField['id'] => 1]));
1087 $this->individualCreate(array_merge($mouseParams, ['id' => $mouse2, 'custom_' . $customField['id'] => 0]));
1088 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => 'safe']);
1089 $this->assertEquals(0, count($result['values']['merged']));
1091 $this->customFieldDelete($customField['id']);
1092 $this->customGroupDelete($customGroup['id']);
1096 * Test the batch merge function actually works!
1098 * @dataProvider getMergeSets
1100 * @param array $dataSet
1102 * @throws \CRM_Core_Exception
1104 public function testBatchMergeWorksCheckPermissionsTrue($dataSet) {
1105 CRM_Core_Config
::singleton()->userPermissionClass
->permissions
= ['access CiviCRM', 'administer CiviCRM', 'merge duplicate contacts', 'force merge duplicate contacts'];
1106 foreach ($dataSet['contacts'] as $params) {
1107 $this->callAPISuccess('Contact', 'create', $params);
1110 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 1, 'mode' => $dataSet['mode']]);
1111 $this->assertCount(0, $result['values']['merged'], 'User does not have permission to any contacts, so no merging');
1112 $this->assertCount(0, $result['values']['skipped'], 'User does not have permission to any contacts, so no skip visibility');
1116 * Test the batch merge function actually works!
1118 * @dataProvider getMergeSets
1120 * @param array $dataSet
1122 * @throws \CRM_Core_Exception
1124 public function testBatchMergeWorksCheckPermissionsFalse($dataSet) {
1125 CRM_Core_Config
::singleton()->userPermissionClass
->permissions
= ['access CiviCRM', 'edit my contact'];
1126 foreach ($dataSet['contacts'] as $params) {
1127 $this->callAPISuccess('Contact', 'create', $params);
1130 $result = $this->callAPISuccess('Job', 'process_batch_merge', ['check_permissions' => 0, 'mode' => $dataSet['mode']]);
1131 $this->assertEquals($dataSet['skipped'], count($result['values']['skipped']), 'Failed to skip the right number:' . $dataSet['skipped']);
1132 $this->assertEquals($dataSet['merged'], count($result['values']['merged']));
1136 * Get data for batch merge.
1138 public function getMergeSets() {
1145 'first_name' => 'Michael',
1146 'last_name' => 'Jackson',
1147 'email' => 'michael@neverland.com',
1148 'contact_type' => 'Individual',
1149 'contact_sub_type' => 'Student',
1150 'api.Address.create' => [
1151 'street_address' => 'big house',
1152 'location_type_id' => 'Home',
1156 'first_name' => 'Michael',
1157 'last_name' => 'Jackson',
1158 'email' => 'michael@neverland.com',
1159 'contact_type' => 'Individual',
1160 'contact_sub_type' => 'Student',
1167 'first_name' => 'Michael',
1168 'last_name' => 'Jackson',
1169 'email' => 'michael@neverland.com',
1170 'contact_type' => 'Individual',
1180 'first_name' => 'Michael',
1181 'last_name' => 'Jackson',
1182 'email' => 'michael@neverland.com',
1183 'contact_type' => 'Individual',
1184 'contact_sub_type' => 'Student',
1185 'api.Address.create' => [
1186 'street_address' => 'big house',
1187 'location_type_id' => 'Home',
1191 'first_name' => 'Michael',
1192 'last_name' => 'Jackson',
1193 'email' => 'michael@neverland.com',
1194 'contact_type' => 'Individual',
1195 'contact_sub_type' => 'Student',
1196 'api.Address.create' => [
1197 'street_address' => 'bigger house',
1198 'location_type_id' => 'Home',
1206 'first_name' => 'Michael',
1207 'last_name' => 'Jackson',
1208 'email' => 'michael@neverland.com',
1209 'contact_type' => 'Individual',
1210 'street_address' => 'big house',
1213 'first_name' => 'Michael',
1214 'last_name' => 'Jackson',
1215 'email' => 'michael@neverland.com',
1216 'contact_type' => 'Individual',
1217 'street_address' => 'bigger house',
1227 'first_name' => 'Michael',
1228 'last_name' => 'Jackson',
1229 'email' => 'michael@neverland.com',
1230 'contact_type' => 'Individual',
1231 'contact_sub_type' => 'Student',
1232 'api.Email.create' => [
1233 'email' => 'big.slog@work.co.nz',
1234 'location_type_id' => 'Work',
1238 'first_name' => 'Michael',
1239 'last_name' => 'Jackson',
1240 'email' => 'michael@neverland.com',
1241 'contact_type' => 'Individual',
1242 'contact_sub_type' => 'Student',
1243 'api.Email.create' => [
1244 'email' => 'big.slog@work.com',
1245 'location_type_id' => 'Work',
1253 'first_name' => 'Michael',
1254 'last_name' => 'Jackson',
1255 'email' => 'michael@neverland.com',
1256 'contact_type' => 'Individual',
1259 'first_name' => 'Michael',
1260 'last_name' => 'Jackson',
1261 'email' => 'michael@neverland.com',
1262 'contact_type' => 'Individual',
1272 'first_name' => 'Michael',
1273 'last_name' => 'Jackson',
1274 'email' => 'michael@neverland.com',
1275 'contact_type' => 'Individual',
1276 'contact_sub_type' => 'Student',
1277 'api.Phone.create' => [
1278 'phone' => '123456',
1279 'location_type_id' => 'Work',
1283 'first_name' => 'Michael',
1284 'last_name' => 'Jackson',
1285 'email' => 'michael@neverland.com',
1286 'contact_type' => 'Individual',
1287 'contact_sub_type' => 'Student',
1288 'api.Phone.create' => [
1290 'location_type_id' => 'Work',
1298 'first_name' => 'Michael',
1299 'last_name' => 'Jackson',
1300 'email' => 'michael@neverland.com',
1301 'contact_type' => 'Individual',
1304 'first_name' => 'Michael',
1305 'last_name' => 'Jackson',
1306 'email' => 'michael@neverland.com',
1307 'contact_type' => 'Individual',
1314 'mode' => 'aggressive',
1317 'first_name' => 'Michael',
1318 'last_name' => 'Jackson',
1319 'email' => 'michael@neverland.com',
1320 'contact_type' => 'Individual',
1321 'contact_sub_type' => 'Student',
1322 'api.Address.create' => [
1323 'street_address' => 'big house',
1324 'location_type_id' => 'Home',
1328 'first_name' => 'Michael',
1329 'last_name' => 'Jackson',
1330 'email' => 'michael@neverland.com',
1331 'contact_type' => 'Individual',
1332 'contact_sub_type' => 'Student',
1333 'api.Address.create' => [
1334 'street_address' => 'bigger house',
1335 'location_type_id' => 'Home',
1343 'first_name' => 'Michael',
1344 'last_name' => 'Jackson',
1345 'email' => 'michael@neverland.com',
1346 'contact_type' => 'Individual',
1347 'street_address' => 'big house',
1357 'first_name' => 'Michael',
1358 'last_name' => 'Jackson',
1359 'email' => 'michael@neverland.com',
1360 'contact_type' => 'Individual',
1361 'contact_sub_type' => 'Student',
1362 'api.Address.create' => [
1363 'street_address' => 'big house',
1364 'location_type_id' => 'Home',
1368 'first_name' => 'Michael',
1369 'last_name' => 'Jackson',
1370 'email' => 'michael@neverland.com',
1371 'contact_type' => 'Individual',
1372 'contact_sub_type' => 'Student',
1380 'first_name' => 'Michael',
1381 'last_name' => 'Jackson',
1382 'email' => 'michael@neverland.com',
1383 'contact_type' => 'Individual',
1387 'first_name' => 'Michael',
1388 'last_name' => 'Jackson',
1389 'email' => 'michael@neverland.com',
1390 'contact_type' => 'Individual',
1401 'first_name' => 'Michael',
1402 'last_name' => 'Jackson',
1403 'email' => 'michael@neverland.com',
1404 'contact_type' => 'Individual',
1405 'contact_sub_type' => 'Student',
1406 'api.Address.create' => [
1407 'street_address' => 'big house',
1408 'location_type_id' => 'Home',
1413 'first_name' => 'Michael',
1414 'last_name' => 'Jackson',
1415 'email' => 'michael@neverland.com',
1416 'contact_type' => 'Individual',
1417 'contact_sub_type' => 'Student',
1424 'first_name' => 'Michael',
1425 'last_name' => 'Jackson',
1426 'email' => 'michael@neverland.com',
1427 'contact_type' => 'Individual',
1431 'first_name' => 'Michael',
1432 'last_name' => 'Jackson',
1433 'email' => 'michael@neverland.com',
1434 'contact_type' => 'Individual',
1443 'first_name' => 'Dianna',
1444 'last_name' => 'McAndrew',
1445 'middle_name' => 'Prancer',
1446 'birth_date' => '2015-12-25',
1447 'gender_id' => 'Female',
1448 'job_title' => 'Thriller',
1451 foreach ($conflictPairs as $key => $value) {
1453 'first_name' => 'Michael',
1454 'middle_name' => 'Dancer',
1455 'last_name' => 'Jackson',
1456 'birth_date' => '2015-02-25',
1457 'email' => 'michael@neverland.com',
1458 'contact_type' => 'Individual',
1459 'contact_sub_type' => ['Student'],
1460 'gender_id' => 'Male',
1461 'job_title' => 'Entertainer',
1463 $contact2 = $contactParams;
1465 $contact2[$key] = $value;
1466 $data[$key . '_conflict'] = [
1469 'contacts' => [$contactParams, $contact2],
1472 'expected' => [$contactParams, $contact2],
1481 * Implements pre hook on relationships.
1484 * @param string $objectName
1486 * @param array $params
1488 public function hookPreRelationship($op, $objectName, $id, &$params) {
1489 if ($op === 'delete') {
1492 if ($params['is_active']) {
1493 $params['description'] = 'Hooked';
1496 $params['description'] = 'Go Go you good thing';
1501 * Get the location data set.
1503 * @param array $locationParams1
1504 * @param array $locationParams2
1505 * @param string $entity
1506 * @param array $additionalExpected
1510 public function getMergeLocations($locationParams1, $locationParams2, $entity, $additionalExpected = []) {
1513 'matching_primary' => [
1514 'entity' => $entity,
1517 'location_type_id' => 'Main',
1519 ], $locationParams1),
1521 'location_type_id' => 'Work',
1523 ], $locationParams2),
1527 'location_type_id' => 'Main',
1529 ], $locationParams1),
1531 'expected' => array_merge($additionalExpected, [
1533 'location_type_id' => 'Main',
1535 ], $locationParams1),
1537 'location_type_id' => 'Work',
1539 ], $locationParams2),
1541 'expected_hook' => array_merge($additionalExpected, [
1543 'location_type_id' => 'Main',
1545 ], $locationParams1),
1547 'location_type_id' => 'Work',
1549 ], $locationParams2),
1554 'matching_primary_reverse' => [
1555 'entity' => $entity,
1558 'location_type_id' => 'Main',
1560 ], $locationParams1),
1564 'location_type_id' => 'Main',
1566 ], $locationParams1),
1568 'location_type_id' => 'Work',
1570 ], $locationParams2),
1572 'expected' => array_merge($additionalExpected, [
1574 'location_type_id' => 'Main',
1576 ], $locationParams1),
1578 'location_type_id' => 'Work',
1580 ], $locationParams2),
1582 'expected_hook' => array_merge($additionalExpected, [
1584 'location_type_id' => 'Main',
1586 ], $locationParams1),
1588 'location_type_id' => 'Work',
1590 ], $locationParams2),
1595 'only_one_has_address' => [
1596 'entity' => $entity,
1599 'location_type_id' => 'Main',
1601 ], $locationParams1),
1603 'location_type_id' => 'Work',
1605 ], $locationParams2),
1608 'expected' => array_merge($additionalExpected, [
1610 'location_type_id' => 'Main',
1612 ], $locationParams1),
1614 'location_type_id' => 'Work',
1616 ], $locationParams2),
1618 'expected_hook' => array_merge($additionalExpected, [
1620 'location_type_id' => 'Main',
1621 // When dealing with email we don't have a clean slate - the existing
1622 // primary will be primary.
1623 'is_primary' => ($entity == 'Email' ?
0 : 1),
1624 ], $locationParams1),
1626 'location_type_id' => 'Work',
1628 ], $locationParams2),
1633 'only_one_has_address_reverse' => [
1634 'description' => 'The destination contact does not have an address. secondary contact should be merged in.',
1635 'entity' => $entity,
1639 'location_type_id' => 'Main',
1641 ], $locationParams1),
1643 'location_type_id' => 'Work',
1645 ], $locationParams2),
1647 'expected' => array_merge($additionalExpected, [
1649 'location_type_id' => 'Main',
1650 // When dealing with email we don't have a clean slate - the existing
1651 // primary will be primary.
1652 'is_primary' => ($entity == 'Email' ?
0 : 1),
1653 ], $locationParams1),
1655 'location_type_id' => 'Work',
1657 ], $locationParams2),
1659 'expected_hook' => array_merge($additionalExpected, [
1661 'location_type_id' => 'Main',
1663 ], $locationParams1),
1665 'location_type_id' => 'Work',
1667 ], $locationParams2),
1672 'different_primaries_with_different_location_type' => [
1673 'description' => 'Primaries are different with different location. Keep both addresses. Set primary to be that of lower id',
1674 'entity' => $entity,
1677 'location_type_id' => 'Main',
1679 ], $locationParams1),
1683 'location_type_id' => 'Work',
1685 ], $locationParams2),
1687 'expected' => array_merge($additionalExpected, [
1689 'location_type_id' => 'Main',
1691 ], $locationParams1),
1693 'location_type_id' => 'Work',
1695 ], $locationParams2),
1697 'expected_hook' => array_merge($additionalExpected, [
1699 'location_type_id' => 'Main',
1701 ], $locationParams1),
1703 'location_type_id' => 'Work',
1705 ], $locationParams2),
1710 'different_primaries_with_different_location_type_reverse' => [
1711 'entity' => $entity,
1714 'location_type_id' => 'Work',
1716 ], $locationParams2),
1720 'location_type_id' => 'Main',
1722 ], $locationParams1),
1724 'expected' => array_merge($additionalExpected, [
1726 'location_type_id' => 'Work',
1728 ], $locationParams2),
1730 'location_type_id' => 'Main',
1732 ], $locationParams1),
1734 'expected_hook' => array_merge($additionalExpected, [
1736 'location_type_id' => 'Work',
1738 ], $locationParams2),
1740 'location_type_id' => 'Main',
1742 ], $locationParams1),
1747 'different_primaries_location_match_only_one_address' => [
1748 'entity' => $entity,
1751 'location_type_id' => 'Main',
1753 ], $locationParams1),
1755 'location_type_id' => 'Work',
1757 ], $locationParams2),
1761 'location_type_id' => 'Work',
1763 ], $locationParams2),
1766 'expected' => array_merge($additionalExpected, [
1768 'location_type_id' => 'Main',
1770 ], $locationParams1),
1772 'location_type_id' => 'Work',
1774 ], $locationParams2),
1776 'expected_hook' => array_merge($additionalExpected, [
1778 'location_type_id' => 'Main',
1780 ], $locationParams1),
1782 'location_type_id' => 'Work',
1784 ], $locationParams2),
1789 'different_primaries_location_match_only_one_address_reverse' => [
1790 'entity' => $entity,
1793 'location_type_id' => 'Work',
1795 ], $locationParams2),
1799 'location_type_id' => 'Main',
1801 ], $locationParams1),
1803 'location_type_id' => 'Work',
1805 ], $locationParams2),
1807 'expected' => array_merge($additionalExpected, [
1809 'location_type_id' => 'Work',
1811 ], $locationParams2),
1813 'location_type_id' => 'Main',
1815 ], $locationParams1),
1817 'expected_hook' => array_merge($additionalExpected, [
1819 'location_type_id' => 'Work',
1821 ], $locationParams2),
1823 'location_type_id' => 'Main',
1825 ], $locationParams1),
1830 'same_primaries_different_location' => [
1831 'entity' => $entity,
1834 'location_type_id' => 'Main',
1836 ], $locationParams1),
1840 'location_type_id' => 'Work',
1842 ], $locationParams1),
1845 'expected' => array_merge($additionalExpected, [
1847 'location_type_id' => 'Main',
1849 ], $locationParams1),
1851 'location_type_id' => 'Work',
1853 ], $locationParams1),
1855 'expected_hook' => array_merge($additionalExpected, [
1857 'location_type_id' => 'Work',
1859 ], $locationParams1),
1864 'same_primaries_different_location_reverse' => [
1865 'entity' => $entity,
1868 'location_type_id' => 'Work',
1870 ], $locationParams1),
1874 'location_type_id' => 'Main',
1876 ], $locationParams1),
1878 'expected' => array_merge($additionalExpected, [
1880 'location_type_id' => 'Work',
1882 ], $locationParams1),
1884 'location_type_id' => 'Main',
1886 ], $locationParams1),
1888 'expected_hook' => array_merge($additionalExpected, [
1890 'location_type_id' => 'Main',
1892 ], $locationParams1),
1900 * Test processing membership for deceased contacts.
1902 * @throws \CRM_Core_Exception
1904 public function testProcessMembershipDeceased() {
1905 $this->callAPISuccess('Job', 'process_membership', []);
1906 $deadManWalkingID = $this->individualCreate();
1907 $membershipID = $this->contactMembershipCreate(['contact_id' => $deadManWalkingID]);
1908 $this->callAPISuccess('Contact', 'create', ['id' => $deadManWalkingID, 'is_deceased' => 1]);
1909 $this->callAPISuccess('Job', 'process_membership', []);
1910 $membership = $this->callAPISuccessGetSingle('Membership', ['id' => $membershipID]);
1911 $deceasedStatusId = CRM_Core_PseudoConstant
::getKey('CRM_Member_BAO_Membership', 'status_id', 'Deceased');
1912 $this->assertEquals($deceasedStatusId, $membership['status_id']);
1916 * Test we get an error is deceased status is disabled.
1918 * @throws \CRM_Core_Exception
1920 public function testProcessMembershipNoDeceasedStatus() {
1921 $deceasedStatusId = CRM_Core_PseudoConstant
::getKey('CRM_Member_BAO_Membership', 'status_id', 'Deceased');
1922 $this->callAPISuccess('MembershipStatus', 'create', ['is_active' => 0, 'id' => $deceasedStatusId]);
1923 CRM_Core_PseudoConstant
::flush();
1925 $deadManWalkingID = $this->individualCreate();
1926 $this->contactMembershipCreate(['contact_id' => $deadManWalkingID]);
1927 $this->callAPISuccess('Contact', 'create', ['id' => $deadManWalkingID, 'is_deceased' => 1]);
1928 $this->callAPIFailure('Job', 'process_membership', []);
1930 $this->callAPISuccess('MembershipStatus', 'create', ['is_active' => 1, 'id' => $deceasedStatusId]);
1934 * Test processing membership: check that status is updated when it should be
1935 * and left alone when it shouldn't.
1937 * @throws \CRM_Core_Exception
1938 * @throws \CiviCRM_API3_Exception
1940 public function testProcessMembershipUpdateStatus() {
1941 $this->ids
['MembershipType'] = $this->membershipTypeCreate();
1943 // Create admin-only membership status and get all statuses.
1944 $this->callAPISuccess('membership_status', 'create', ['name' => 'Admin', 'is_admin' => 1])['id'];
1946 // Create membership with incorrect statuses for the given dates and also some (pending, cancelled, admin override) which should not be updated.
1949 'start_date' => 'now',
1950 'end_date' => '+ 1 year',
1951 'initial_status' => 'Current',
1952 'expected_processed_status' => 'New',
1955 'start_date' => '- 6 month',
1956 'end_date' => '+ 6 month',
1957 'initial_status' => 'New',
1958 'expected_processed_status' => 'Current',
1961 'start_date' => '- 53 week',
1962 'end_date' => '-1 week',
1963 'initial_status' => 'Current',
1964 'expected_processed_status' => 'Grace',
1967 'start_date' => '- 16 month',
1968 'end_date' => '- 4 month',
1969 'initial_status' => 'Grace',
1970 'expected_processed_status' => 'Expired',
1973 'start_date' => 'now',
1974 'end_date' => '+ 1 year',
1975 'initial_status' => 'Pending',
1976 'expected_processed_status' => 'Pending',
1979 'start_date' => '- 6 month',
1980 'end_date' => '+ 6 month',
1981 'initial_status' => 'Cancelled',
1982 'expected_processed_status' => 'Cancelled',
1985 'start_date' => '- 16 month',
1986 'end_date' => '- 4 month',
1987 'initial_status' => 'Current',
1988 'is_override' => TRUE,
1989 'expected_processed_status' => 'Current',
1992 // @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.
1993 // 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.
1994 'start_date' => '- 16 month',
1995 'end_date' => '- 4 month',
1996 'initial_status' => 'Admin',
1997 'is_override' => TRUE,
1998 'expected_processed_status' => 'Admin',
2001 foreach ($memberships as $index => $membership) {
2002 $memberships[$index]['id'] = $this->createMembershipNeedingStatusProcessing($membership['start_date'], $membership['end_date'], $membership['initial_status'], $membership['is_override'] ??
FALSE);
2006 * Create membership type with inheritence and check processing of secondary memberships.
2008 $employerRelationshipId = $this->callAPISuccessGetValue('RelationshipType', [
2010 'name_b_a' => 'Employer Of',
2012 // Create membership type: inherited through employment.
2013 $membershipOrgId = $this->organizationCreate();
2015 'name' => 'Corporate Membership',
2016 'duration_unit' => 'year',
2017 'duration_interval' => 1,
2018 'period_type' => 'rolling',
2019 'member_of_contact_id' => $membershipOrgId,
2021 'financial_type_id' => 1,
2022 'relationship_type_id' => $employerRelationshipId,
2023 'relationship_direction' => 'b_a',
2026 $membershipTypeId = $this->callAPISuccess('membership_type', 'create', $params)['id'];
2028 // Create employer and first employee
2029 $employerId = $this->organizationCreate([], 1);
2030 $memberContactId = $this->individualCreate(['employer_id' => $employerId], 0);
2032 // Create inherited membership with incorrect status but dates implying status Expired.
2034 'contact_id' => $employerId,
2035 'membership_type_id' => $membershipTypeId,
2036 'source' => 'Test suite',
2037 'join_date' => date('Y-m-d', strtotime('now - 16 month')),
2038 'start_date' => date('Y-m-d', strtotime('now - 16 month')),
2039 'end_date' => date('Y-m-d', strtotime('now - 4 month')),
2040 // Intentionally incorrect status.
2041 'status_id' => 'Grace',
2042 // Don't calculate status.
2043 'skipStatusCal' => 1,
2045 $organizationMembershipID = $this->contactMembershipCreate($params);
2047 // Check that the employee inherited the membership and status.
2048 $expiredInheritedRelationship = $this->callAPISuccessGetSingle('membership', [
2049 'contact_id' => $memberContactId,
2050 'membership_type_id' => $membershipTypeId,
2052 $this->assertEquals($organizationMembershipID, $expiredInheritedRelationship['owner_membership_id']);
2053 $this->assertMembershipStatus('Grace', (int) $expiredInheritedRelationship['status_id']);
2055 // Reset static $relatedContactIds array in createRelatedMemberships(),
2056 // to avoid bug where inherited membership gets deleted.
2058 CRM_Member_BAO_Membership
::createRelatedMemberships($var, $var, TRUE);
2059 // Check that after running process_membership job, statuses are correct.
2060 $this->callAPISuccess('Job', 'process_membership', []);
2062 foreach ($memberships as $expectation) {
2063 $membership = $this->callAPISuccessGetSingle('membership', ['id' => $expectation['id']]);
2064 $this->assertMembershipStatus($expectation['expected_processed_status'], (int) $membership['status_id']);
2067 // Inherit Expired - should get updated.
2068 $membership = $this->callAPISuccess('membership', 'getsingle', ['id' => $expiredInheritedRelationship['id']]);
2069 $this->assertMembershipStatus('Expired', $membership['status_id']);
2073 * Test procesing membership where is_override is set to 0 rather than NULL
2075 * @throws \CRM_Core_Exception
2077 public function testProcessMembershipIsOverrideNotNullNot1either() {
2078 $membershipTypeId = $this->membershipTypeCreate();
2080 // Create admin-only membership status and get all statuses.
2081 $result = $this->callAPISuccess('membership_status', 'create', ['name' => 'Admin', 'is_admin' => 1, 'sequential' => 1]);
2082 $membershipStatusIdAdmin = $result['values'][0]['id'];
2083 $memStatus = CRM_Member_PseudoConstant
::membershipStatus();
2085 // Default params, which we'll expand on below.
2087 'membership_type_id' => $membershipTypeId,
2088 // Don't calculate status.
2089 'skipStatusCal' => 1,
2094 // Create membership with incorrect status but dates implying status Current.
2095 $params['contact_id'] = $this->individualCreate();
2096 $params['join_date'] = date('Y-m-d', strtotime('now - 6 month'));
2097 $params['start_date'] = date('Y-m-d', strtotime('now - 6 month'));
2098 $params['end_date'] = date('Y-m-d', strtotime('now + 6 month'));
2099 // Intentionally incorrect status.
2100 $params['status_id'] = 'New';
2101 $resultCurrent = $this->callAPISuccess('Membership', 'create', $params);
2102 // Ensure that is_override is set to 0 by doing through DB given API not seem to accept id
2103 CRM_Core_DAO
::executeQuery("Update civicrm_membership SET is_override = 0 WHERE id = %1", [1 => [$resultCurrent['id'], 'Positive']]);
2104 $this->assertEquals(array_search('New', $memStatus, TRUE), $resultCurrent['values'][0]['status_id']);
2105 $jobResult = $this->callAPISuccess('Job', 'process_membership', []);
2106 $this->assertEquals('Processed 1 membership records. Updated 1 records.', $jobResult['values']);
2107 $this->assertEquals(array_search('Current', $memStatus, TRUE), $this->callAPISuccess('Membership', 'get', ['id' => $resultCurrent['id']])['values'][$resultCurrent['id']]['status_id']);
2111 * @param string $expectedStatusName
2112 * @param int $actualStatusID
2114 protected function assertMembershipStatus(string $expectedStatusName, int $actualStatusID) {
2115 $this->assertEquals($expectedStatusName, CRM_Core_PseudoConstant
::getName('CRM_Member_BAO_Membership', 'status_id', $actualStatusID));
2119 * @param string $startDate
2120 * Date in strtotime format - e.g 'now', '+1 day'
2121 * @param string $endDate
2122 * Date in strtotime format - e.g 'now', '+1 day'
2123 * @param string $status
2125 * @param bool $isAdminOverride
2126 * Is administratively overridden (if so the status is fixed).
2130 * @throws \CRM_Core_Exception
2132 protected function createMembershipNeedingStatusProcessing(string $startDate, string $endDate, string $status, bool $isAdminOverride = FALSE): int {
2134 'membership_type_id' => $this->ids
['MembershipType'],
2135 // Don't calculate status.
2136 'skipStatusCal' => 1,
2140 $params['contact_id'] = $this->individualCreate();
2141 $params['join_date'] = date('Y-m-d', strtotime($startDate));
2142 $params['start_date'] = date('Y-m-d', strtotime($startDate));
2143 $params['end_date'] = date('Y-m-d', strtotime($endDate));
2144 $params['sequential'] = TRUE;
2145 $params['is_override'] = $isAdminOverride;
2146 // Intentionally incorrect status.
2147 $params['status_id'] = $status;
2148 $resultNew = $this->callAPISuccess('Membership', 'create', $params);
2149 $this->assertMembershipStatus($status, (int) $resultNew['values'][0]['status_id']);
2150 return (int) $resultNew['id'];
2154 * Shared set up for SMS reminder tests.
2158 * @throws \CRM_Core_Exception
2159 * @throws \CiviCRM_API3_Exception
2161 protected function setUpMembershipSMSReminders(): array {
2162 $membershipTypeID = $this->membershipTypeCreate();
2163 $this->membershipStatusCreate();
2165 $groupID = $this->groupCreate(['name' => 'Texan drawlers', 'title' => 'a...']);
2166 for ($i = 1; $i <= $createTotal; $i++
) {
2167 $contactID = $this->individualCreate();
2168 $this->callAPISuccess('Phone', 'create', [
2169 'contact_id' => $contactID,
2170 'phone' => '555 123 1234',
2171 'phone_type_id' => 'Mobile',
2172 'location_type_id' => 'Billing',
2175 $theChosenOneID = $contactID;
2178 $this->callAPISuccess('group_contact', 'create', [
2179 'contact_id' => $contactID,
2180 'status' => 'Added',
2181 'group_id' => $groupID,
2185 $this->callAPISuccess('membership', 'create', [
2186 'contact_id' => $contactID,
2187 'membership_type_id' => $membershipTypeID,
2188 'join_date' => 'now',
2189 'start_date' => '+ 1 day',
2193 $this->setupForSmsTests();
2194 $provider = civicrm_api3('SmsProvider', 'create', [
2195 'name' => 'CiviTestSMSProvider',
2200 'api_params' => 'a=1',
2201 'is_default' => '1',
2205 return [$membershipTypeID, $groupID, $theChosenOneID, $provider];