Respect zero value
[civicrm-core.git] / tests / phpunit / CRM / Utils / TokenConsistencyTest.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 use Civi\Token\TokenProcessor;
13 use Civi\Api4\LocBlock;
14 use Civi\Api4\Email;
15 use Civi\Api4\Phone;
16 use Civi\Api4\Address;
17
18 /**
19 * CRM_Utils_TokenConsistencyTest
20 *
21 * Class for ensuring tokens have internal consistency.
22 *
23 * @group Tokens
24 *
25 * @group headless
26 */
27 class CRM_Utils_TokenConsistencyTest extends CiviUnitTestCase {
28
29 use CRMTraits_Custom_CustomDataTrait;
30
31 /**
32 * Created case.
33 *
34 * @var array
35 */
36 protected $case;
37
38 /**
39 * Recurring contribution.
40 *
41 * @var array
42 */
43 protected $contributionRecur;
44
45 /**
46 * Post test cleanup.
47 */
48 public function tearDown(): void {
49 $this->quickCleanup(['civicrm_case', 'civicrm_case_type', 'civicrm_participant', 'civicrm_event'], TRUE);
50 $this->quickCleanUpFinancialEntities();
51
52 // WORKAROUND: CRM_Event_Tokens copies `civicrm_event` data into metadata cache. That should probably change, but that's a different scope-of-work.
53 // `clear()` works around it. This should be removed if that's updated, but it will be safe either way.
54 Civi::cache('metadata')->clear();
55
56 parent::tearDown();
57 }
58
59 /**
60 * Test that case tokens are consistently rendered.
61 *
62 * @throws \CiviCRM_API3_Exception
63 */
64 public function testCaseTokenConsistency(): void {
65 $this->createLoggedInUser();
66 CRM_Core_BAO_ConfigSetting::enableComponent('CiviCase');
67 $this->createCustomGroupWithFieldOfType(['extends' => 'Case']);
68 $tokens = CRM_Core_SelectValues::caseTokens();
69 $this->assertEquals($this->getCaseTokens(), $tokens);
70 $caseID = $this->getCaseID();
71 $tokenString = $this->getTokenString(array_keys($this->getCaseTokens()));
72 $tokenHtml = CRM_Utils_Token::replaceCaseTokens($caseID, $tokenString, ['case' => $this->getCaseTokenKeys()]);
73 $this->assertEquals($this->getExpectedCaseTokenOutput(), $tokenHtml);
74 // Now do the same without passing in 'knownTokens'
75 $tokenHtml = CRM_Utils_Token::replaceCaseTokens($caseID, $tokenString);
76 $this->assertEquals($this->getExpectedCaseTokenOutput(), $tokenHtml);
77
78 // And check our deprecated tokens still work.
79 $tokenHtml = CRM_Utils_Token::replaceCaseTokens($caseID, '{case.case_type_id} {case.status_id}');
80 $this->assertEquals('Housing Support Ongoing', $tokenHtml);
81 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
82 'controller' => __CLASS__,
83 'smarty' => FALSE,
84 'schema' => ['caseId'],
85 ]);
86 $this->assertEquals(array_merge($this->getCaseTokens(), $this->getDomainTokens()), $tokenProcessor->listTokens());
87 $tokenProcessor->addRow([
88 'caseId' => $this->getCaseID(),
89 ]);
90 $tokenProcessor->addMessage('html', $tokenString, 'text/plain');
91
92 $tokenProcessor->evaluate();
93 foreach ($tokenProcessor->getRows() as $row) {
94 $text = $row->render('html');
95 }
96 $this->assertEquals($this->getExpectedCaseTokenOutput(), $text);
97 }
98
99 /**
100 * Get expected output from token parsing.
101 *
102 * @return string
103 */
104 protected function getExpectedCaseTokenOutput(): string {
105 return 'case.id :1
106 case.case_type_id:label :Housing Support
107 case.subject :Case Subject
108 case.start_date :July 23rd, 2021
109 case.end_date :July 26th, 2021
110 case.details :case details
111 case.status_id:label :Ongoing
112 case.is_deleted:label :No
113 case.created_date :' . CRM_Utils_Date::customFormat($this->case['created_date']) . '
114 case.modified_date :' . CRM_Utils_Date::customFormat($this->case['modified_date']) . '
115 case.custom_1 :' . '
116 ';
117 }
118
119 /**
120 * @return int
121 */
122 protected function getContactID(): int {
123 if (!isset($this->ids['Contact'][0])) {
124 $this->ids['Contact'][0] = $this->individualCreate();
125 }
126 return $this->ids['Contact'][0];
127 }
128
129 /**
130 * Get the keys for the case tokens.
131 *
132 * @return array
133 */
134 public function getCaseTokenKeys(): array {
135 $return = [];
136 foreach (array_keys($this->getCaseTokens()) as $key) {
137 $return[] = substr($key, 6, -1);
138 }
139 return $return;
140 }
141
142 /**
143 * Get declared tokens.
144 *
145 * @return string[]
146 */
147 public function getCaseTokens(): array {
148 return [
149 '{case.id}' => 'Case ID',
150 '{case.case_type_id:label}' => 'Case Type',
151 '{case.subject}' => 'Case Subject',
152 '{case.start_date}' => 'Case Start Date',
153 '{case.end_date}' => 'Case End Date',
154 '{case.details}' => 'Details',
155 '{case.status_id:label}' => 'Case Status',
156 '{case.is_deleted:label}' => 'Case is in the Trash',
157 '{case.created_date}' => 'Created Date',
158 '{case.modified_date}' => 'Modified Date',
159 '{case.custom_1}' => 'Enter text here :: Group with field text',
160 ];
161 }
162
163 /**
164 * Get case ID.
165 *
166 * @return int
167 */
168 protected function getCaseID(): int {
169 if (!isset($this->case)) {
170 $case_id = $this->callAPISuccess('Case', 'create', [
171 'case_type_id' => 'housing_support',
172 'activity_subject' => 'Case Subject',
173 'client_id' => $this->getContactID(),
174 'status_id' => 1,
175 'subject' => 'Case Subject',
176 'start_date' => '2021-07-23 15:39:20',
177 // Note end_date is inconsistent with status Ongoing but for the
178 // purposes of testing tokens is ok. Creating it with status Resolved
179 // then ignores our known fixed end date.
180 'end_date' => '2021-07-26 18:07:20',
181 'medium_id' => 2,
182 'details' => 'case details',
183 'activity_details' => 'blah blah',
184 'sequential' => 1,
185 ])['id'];
186 // Need to retrieve the case again because modified date might be updated a
187 // split-second later than the original return value because of activity
188 // triggers when the timeline is populated. The returned array from create
189 // is determined before that happens.
190 $this->case = $this->callAPISuccess('Case', 'getsingle', ['id' => $case_id]);
191 }
192 return $this->case['id'];
193 }
194
195 /**
196 * Test that contribution recur tokens are consistently rendered.
197 */
198 public function testContributionRecurTokenConsistency(): void {
199 $this->createLoggedInUser();
200 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
201 'controller' => __CLASS__,
202 'smarty' => FALSE,
203 'schema' => ['contribution_recurId'],
204 ]);
205 $expectedTokens = array_merge($this->getContributionRecurTokens(), $this->getDomainTokens());
206 $this->assertEquals(array_diff_key($expectedTokens, $this->getUnadvertisedTokens()), $tokenProcessor->listTokens());
207 $tokenString = $this->getTokenString(array_keys($this->getContributionRecurTokens()));
208
209 $tokenProcessor->addMessage('html', $tokenString, 'text/plain');
210 $tokenProcessor->addRow(['contribution_recurId' => $this->getContributionRecurID()]);
211 $tokenProcessor->evaluate();
212 $this->assertEquals($this->getExpectedContributionRecurTokenOutPut(), $tokenProcessor->getRow(0)->render('html'));
213 }
214
215 /**
216 * Test that contribution recur tokens are consistently rendered.
217 */
218 public function testContributionRecurTokenRaw(): void {
219 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
220 'controller' => __CLASS__,
221 'smarty' => FALSE,
222 'schema' => ['contribution_recurId'],
223 ]);
224 $tokenProcessor->addMessage('not_specified', '{contribution_recur.amount}', 'text/plain');
225 $tokenProcessor->addMessage('money', '{contribution_recur.amount|crmMoney}', 'text/plain');
226 $tokenProcessor->addMessage('raw', '{contribution_recur.amount|raw}', 'text/plain');
227 $tokenProcessor->addMessage('moneyNumber', '{contribution_recur.amount|crmMoneyNumber}', 'text/plain');
228 $tokenProcessor->addRow(['contribution_recurId' => $this->getContributionRecurID()]);
229 $tokenProcessor->evaluate();
230 $this->assertEquals('€5,990.99', $tokenProcessor->getRow(0)->render('not_specified'));
231 $this->assertEquals('€5,990.99', $tokenProcessor->getRow(0)->render('money'));
232 $this->assertEquals('5990.99', $tokenProcessor->getRow(0)->render('raw'));
233 }
234
235 /**
236 * Test money format tokens can respect passed in locale.
237 */
238 public function testMoneyFormat(): void {
239 // Our 'migration' off configured thousand separators at the moment is a define.
240 putenv('IGNORE_SEPARATOR_CONFIG=1');
241 $this->createLoggedInUser();
242 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
243 'controller' => __CLASS__,
244 'smarty' => FALSE,
245 'schema' => ['contribution_recurId'],
246 ]);
247 $tokenString = '{contribution_recur.amount}';
248 $tokenProcessor->addMessage('html', $tokenString, 'text/plain');
249 $tokenProcessor->addRow([
250 'contribution_recurId' => $this->getContributionRecurID(),
251 'locale' => 'nb_NO',
252 ]);
253 $tokenProcessor->evaluate();
254 $this->assertEquals('€ 5 990,99', $tokenProcessor->getRow(0)->render('html'));
255 }
256
257 /**
258 * Get tokens that are not advertised via listTokens.
259 *
260 * @return string[]
261 */
262 public function getUnadvertisedTokens(): array {
263 return [
264 '{membership.status_id}' => 'Status ID',
265 '{membership.membership_type_id}' => 'Membership Type ID',
266 '{membership.status_id:name}' => 'Machine name: Status',
267 '{membership.membership_type_id:name}' => 'Machine name: Membership Type',
268 '{contribution_recur.frequency_unit}' => 'Frequency Unit',
269 '{contribution_recur.contribution_status_id}' => 'Status',
270 '{contribution_recur.payment_processor_id}' => 'Payment Processor ID',
271 '{contribution_recur.financial_type_id}' => 'Financial Type ID',
272 '{contribution_recur.payment_instrument_id}' => 'Payment Method',
273 '{contribution_recur.frequency_unit:name}' => 'Machine name: Frequency Unit',
274 '{contribution_recur.payment_instrument_id:name}' => 'Machine name: Payment Method',
275 '{contribution_recur.contribution_status_id:name}' => 'Machine name: Status',
276 '{contribution_recur.payment_processor_id:name}' => 'Machine name: Payment Processor',
277 '{contribution_recur.financial_type_id:name}' => 'Machine name: Financial Type',
278 '{participant.status_id:name}' => 'Machine name: Status',
279 '{participant.role_id:name}' => 'Machine name: Participant Role',
280 '{participant.status_id}' => 'Status ID',
281 '{participant.role_id}' => 'Participant Role ID',
282 ];
283 }
284
285 /**
286 * Test tokens in 2 ways to ensure consistent handling.
287 *
288 * 1) as part of the greeting processing
289 * 2) via the token processor.
290 *
291 */
292 public function testOddTokens(): void {
293
294 $variants = [
295 [
296 'string' => '{contact.individual_prefix}{ }{contact.first_name}{ }{contact.middle_name}{ }{contact.last_name}{ }{contact.individual_suffix}',
297 'expected' => 'Mr. Anthony Anderson II',
298 ],
299 [
300 'string' => '{contact.prefix_id:label}{ }{contact.first_name}{ }{contact.middle_name}{ }{contact.last_name}{ }{contact.suffix_id:label}',
301 'expected' => 'Mr. Anthony Anderson II',
302 ],
303 ];
304 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
305 'smarty' => FALSE,
306 'schema' => ['contactId'],
307 ]);
308 $contactID = $this->individualCreate(['middle_name' => '']);
309 $tokenProcessor->addRow(['contactId' => $contactID]);
310 foreach ($variants as $index => $variant) {
311 $tokenProcessor->addMessage($index, $variant['string'], 'text/plain');
312 }
313 $tokenProcessor->evaluate();
314 $result = $tokenProcessor->getRow(0);
315 foreach ($variants as $index => $variant) {
316 $greetingString = $variant['string'];
317 CRM_Utils_Token::replaceGreetingTokens($greetingString, $this->callAPISuccessGetSingle('Contact', ['id' => $contactID]), $contactID);
318 $this->assertEquals($variant['expected'], $greetingString, 'replaceGreetingTokens() should render expected output');
319 $this->assertEquals($variant['expected'], $result->render($index), 'TokenProcessor should render expected output');
320 }
321 }
322
323 /**
324 * Get the contribution recur tokens keyed by the token.
325 *
326 * e.g {contribution_recur.id}
327 *
328 * @return array
329 */
330 protected function getContributionRecurTokens(): array {
331 $return = [];
332 foreach ($this->getContributionRecurTokensByField() as $key => $value) {
333 $return['{contribution_recur.' . $key . '}'] = $value;
334 }
335 return $return;
336 }
337
338 protected function getContributionRecurTokensByField(): array {
339 return [
340 'id' => 'Recurring Contribution ID',
341 'amount' => 'Amount',
342 'currency' => 'Currency',
343 'frequency_unit' => 'Frequency Unit',
344 'frequency_interval' => 'Interval (number of units)',
345 'installments' => 'Number of Installments',
346 'start_date' => 'Start Date',
347 'create_date' => 'Created Date',
348 'modified_date' => 'Modified Date',
349 'cancel_date' => 'Cancel Date',
350 'cancel_reason' => 'Cancellation Reason',
351 'end_date' => 'Recurring Contribution End Date',
352 'processor_id' => 'Processor ID',
353 'payment_token_id' => 'Payment Token ID',
354 'trxn_id' => 'Transaction ID',
355 'invoice_id' => 'Invoice ID',
356 'contribution_status_id' => 'Status',
357 'is_test:label' => 'Test',
358 'cycle_day' => 'Cycle Day',
359 'next_sched_contribution_date' => 'Next Scheduled Contribution Date',
360 'failure_count' => 'Number of Failures',
361 'failure_retry_date' => 'Retry Failed Attempt Date',
362 'auto_renew:label' => 'Auto Renew',
363 'payment_processor_id' => 'Payment Processor ID',
364 'financial_type_id' => 'Financial Type ID',
365 'payment_instrument_id' => 'Payment Method',
366 'is_email_receipt:label' => 'Send email Receipt?',
367 'frequency_unit:label' => 'Frequency Unit',
368 'frequency_unit:name' => 'Machine name: Frequency Unit',
369 'contribution_status_id:label' => 'Status',
370 'contribution_status_id:name' => 'Machine name: Status',
371 'payment_processor_id:label' => 'Payment Processor',
372 'payment_processor_id:name' => 'Machine name: Payment Processor',
373 'financial_type_id:label' => 'Financial Type',
374 'financial_type_id:name' => 'Machine name: Financial Type',
375 'payment_instrument_id:label' => 'Payment Method',
376 'payment_instrument_id:name' => 'Machine name: Payment Method',
377 ];
378 }
379
380 /**
381 * Get contributionRecur ID.
382 *
383 * @return int
384 */
385 protected function getContributionRecurID(): int {
386 if (!isset($this->contributionRecur)) {
387 $paymentProcessorID = $this->processorCreate();
388 $this->contributionRecur = $this->callAPISuccess('ContributionRecur', 'create', [
389 'contact_id' => $this->getContactID(),
390 'status_id' => 1,
391 'is_email_receipt' => 1,
392 'start_date' => '2021-07-23 15:39:20',
393 'end_date' => '2021-07-26 18:07:20',
394 'cancel_date' => '2021-08-19 09:12:45',
395 'next_sched_contribution_date' => '2021-09-08',
396 'cancel_reason' => 'Because',
397 'amount' => 5990.99,
398 'currency' => 'EUR',
399 'frequency_unit' => 'year',
400 'frequency_interval' => 2,
401 'installments' => 24,
402 'payment_instrument_id' => 'Check',
403 'financial_type_id' => 'Member dues',
404 'processor_id' => 'abc',
405 'payment_processor_id' => $paymentProcessorID,
406 'trxn_id' => 123,
407 'invoice_id' => 'inv123',
408 'sequential' => 1,
409 'failure_retry_date' => '2020-01-03',
410 'auto_renew' => 1,
411 'cycle_day' => '15',
412 'is_test' => TRUE,
413 'payment_token_id' => $this->callAPISuccess('PaymentToken', 'create', [
414 'contact_id' => $this->getContactID(),
415 'token' => 456,
416 'payment_processor_id' => $paymentProcessorID,
417 ])['id'],
418 ])['values'][0];
419 }
420 return $this->contributionRecur['id'];
421 }
422
423 /**
424 * Get rendered output for contribution tokens.
425 *
426 * @return string
427 */
428 protected function getExpectedContributionRecurTokenOutPut(): string {
429 return 'contribution_recur.id :' . $this->getContributionRecurID() . '
430 contribution_recur.amount :€5,990.99
431 contribution_recur.currency :EUR
432 contribution_recur.frequency_unit :year
433 contribution_recur.frequency_interval :2
434 contribution_recur.installments :24
435 contribution_recur.start_date :July 23rd, 2021 3:39 PM
436 contribution_recur.create_date :' . CRM_Utils_Date::customFormat($this->contributionRecur['create_date']) . '
437 contribution_recur.modified_date :' . CRM_Utils_Date::customFormat($this->contributionRecur['modified_date']) . '
438 contribution_recur.cancel_date :August 19th, 2021 9:12 AM
439 contribution_recur.cancel_reason :Because
440 contribution_recur.end_date :July 26th, 2021 6:07 PM
441 contribution_recur.processor_id :abc
442 contribution_recur.payment_token_id :1
443 contribution_recur.trxn_id :123
444 contribution_recur.invoice_id :inv123
445 contribution_recur.contribution_status_id :2
446 contribution_recur.is_test:label :Yes
447 contribution_recur.cycle_day :15
448 contribution_recur.next_sched_contribution_date :September 8th, 2021
449 contribution_recur.failure_count :0
450 contribution_recur.failure_retry_date :January 3rd, 2020
451 contribution_recur.auto_renew:label :Yes
452 contribution_recur.payment_processor_id :1
453 contribution_recur.financial_type_id :2
454 contribution_recur.payment_instrument_id :4
455 contribution_recur.is_email_receipt:label :Yes
456 contribution_recur.frequency_unit:label :year
457 contribution_recur.frequency_unit:name :year
458 contribution_recur.contribution_status_id:label :Pending Label**
459 contribution_recur.contribution_status_id:name :Pending
460 contribution_recur.payment_processor_id:label :Dummy (test)
461 contribution_recur.payment_processor_id:name :Dummy_test
462 contribution_recur.financial_type_id:label :Member Dues
463 contribution_recur.financial_type_id:name :Member Dues
464 contribution_recur.payment_instrument_id:label :Check
465 contribution_recur.payment_instrument_id:name :Check
466 ';
467
468 }
469
470 /**
471 * Test that membership tokens are consistently rendered.
472 *
473 * @throws \API_Exception
474 */
475 public function testMembershipTokenConsistency(): void {
476 $this->createLoggedInUser();
477 $this->restoreMembershipTypes();
478 $this->createCustomGroupWithFieldOfType(['extends' => 'Membership']);
479 $tokens = CRM_Core_SelectValues::membershipTokens();
480 $expectedTokens = $this->getMembershipTokens();
481 $this->assertEquals($expectedTokens, $tokens);
482 $newStyleTokens = "\n{membership.status_id:label}\n{membership.membership_type_id:label}\n";
483 $tokenString = $newStyleTokens . implode("\n", array_keys($this->getMembershipTokens()));
484
485 $memberships = CRM_Utils_Token::getMembershipTokenDetails([$this->getMembershipID()]);
486 $messageToken = CRM_Utils_Token::getTokens($tokenString);
487 $tokenHtml = CRM_Utils_Token::replaceEntityTokens('membership', $memberships[$this->getMembershipID()], $tokenString, $messageToken);
488 $this->assertEquals($this->getExpectedMembershipTokenOutput(), $tokenHtml);
489
490 // Custom fields work in the processor so test it....
491 $tokenString .= "\n{membership." . $this->getCustomFieldName('text') . '}';
492 // Now compare with scheduled reminder
493 $mut = new CiviMailUtils($this);
494 CRM_Utils_Time::setTime('2007-01-22 15:00:00');
495 $this->callAPISuccess('action_schedule', 'create', [
496 'title' => 'job',
497 'subject' => 'job',
498 'entity_value' => 1,
499 'mapping_id' => 4,
500 'start_action_date' => 'membership_join_date',
501 'start_action_offset' => 1,
502 'start_action_condition' => 'after',
503 'start_action_unit' => 'day',
504 'body_html' => $tokenString,
505 ]);
506 $this->callAPISuccess('job', 'send_reminder', []);
507 $expected = $this->getExpectedMembershipTokenOutput();
508 // Unlike the legacy method custom fields are resolved by the processor.
509 $expected .= "\nmy field";
510 $mut->checkMailLog([$expected]);
511
512 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
513 'controller' => __CLASS__,
514 'smarty' => FALSE,
515 'schema' => ['membershipId'],
516 ]);
517 $tokens = $tokenProcessor->listTokens();
518 // Add in custom tokens as token processor supports these.
519 $expectedTokens = array_merge($expectedTokens, $this->getTokensAdvertisedByTokenProcessorButNotLegacy());
520 $this->assertEquals(array_merge($expectedTokens, $this->getDomainTokens()), $tokens);
521 $tokenProcessor->addMessage('html', $tokenString, 'text/plain');
522 $tokenProcessor->addRow(['membershipId' => $this->getMembershipID()]);
523 $tokenProcessor->evaluate();
524 $this->assertEquals($expected, $tokenProcessor->getRow(0)->render('html'));
525
526 }
527
528 /**
529 * Get the advertised tokens the legacy function doesn't know about.
530 *
531 * @return string[]
532 */
533 public function getTokensAdvertisedByTokenProcessorButNotLegacy(): array {
534 return [
535 '{membership.custom_1}' => 'Enter text here :: Group with field text',
536 '{membership.source}' => 'Source',
537 '{membership.status_override_end_date}' => 'Status Override End Date',
538 ];
539 }
540
541 /**
542 * Get declared membership tokens.
543 *
544 * @return string[]
545 */
546 public function getMembershipTokens(): array {
547 return [
548 '{membership.id}' => 'Membership ID',
549 '{membership.status_id:label}' => 'Status',
550 '{membership.membership_type_id:label}' => 'Membership Type',
551 '{membership.start_date}' => 'Membership Start Date',
552 '{membership.join_date}' => 'Member Since',
553 '{membership.end_date}' => 'Membership Expiration Date',
554 '{membership.fee}' => 'Membership Fee',
555 ];
556 }
557
558 /**
559 * Get case ID.
560 *
561 * @return int
562 */
563 protected function getMembershipID(): int {
564 if (!isset($this->ids['Membership'][0])) {
565 $this->ids['Membership'][0] = $this->contactMembershipCreate([
566 'contact_id' => $this->getContactID(),
567 $this->getCustomFieldName('text') => 'my field',
568 ]);
569 }
570 return $this->ids['Membership'][0];
571 }
572
573 /**
574 * Get expected output from token parsing.
575 *
576 * @return string
577 */
578 protected function getExpectedParticipantTokenOutput(): string {
579 return 'participant.status_id :2
580 participant.role_id :1
581 participant.register_date :February 19th, 2007
582 participant.source :Wimbeldon
583 participant.fee_level :steep
584 participant.fee_amount :$50.00
585 participant.registered_by_id :
586 participant.transferred_to_contact_id :
587 participant.role_id:label :Attendee
588 participant.balance :
589 participant.custom_2 :99999
590 participant.id :2
591 participant.fee_currency :USD
592 participant.discount_amount :
593 participant.status_id:label :Attended
594 participant.status_id:name :Attended
595 participant.role_id:name :Attendee
596 participant.is_test:label :No
597 participant.must_wait :
598 ';
599 }
600
601 /**
602 * Get expected output from token parsing.
603 *
604 * @return string
605 */
606 protected function getExpectedEventTokenOutput(): string {
607 return 'event.id :' . $this->ids['event'][0] . '
608 event.title :Annual CiviCRM meet
609 event.start_date :October 21st, 2008
610 event.end_date :October 23rd, 2008
611 event.event_tz:label :America/New York
612 event.event_type_id:label :Conference
613 event.summary :If you have any CiviCRM related issues or want to track where CiviCRM is heading, Sign up now
614 event.contact_email :event@example.com
615 event.contact_phone :456 789
616 event.description :event description
617 event.location :15 Walton St
618 Emerald City, Maine 90210
619
620 event.info_url :' . CRM_Utils_System::url('civicrm/event/info', NULL, TRUE) . '&reset=1&id=1
621 event.registration_url :' . CRM_Utils_System::url('civicrm/event/register', NULL, TRUE) . '&reset=1&id=1
622 event.pay_later_receipt :
623 event.custom_1 :my field
624 ';
625 }
626
627 /**
628 * Get expected output from token parsing.
629 *
630 * @return string
631 */
632 protected function getExpectedMembershipTokenOutput(): string {
633 return '
634 Expired
635 General
636 1
637 Expired
638 General
639 January 21st, 2007
640 January 21st, 2007
641 December 21st, 2007
642 100.00';
643 }
644
645 /**
646 * Test that membership tokens are consistently rendered.
647 *
648 * @throws \API_Exception
649 */
650 public function testParticipantTokenConsistency(): void {
651 $this->createLoggedInUser();
652 $this->setupParticipantScheduledReminder();
653
654 $tokens = CRM_Core_SelectValues::participantTokens();
655 $this->assertEquals(array_diff_key($this->getParticipantTokens(), $this->getUnadvertisedTokens()), $tokens);
656
657 $mut = new CiviMailUtils($this);
658
659 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
660 'controller' => __CLASS__,
661 'smarty' => FALSE,
662 'schema' => ['participantId'],
663 ]);
664 $this->assertEquals(array_merge($tokens, $this->getEventTokens(), $this->getDomainTokens()), $tokenProcessor->listTokens());
665
666 $this->callAPISuccess('job', 'send_reminder', []);
667 $expected = $this->getExpectedParticipantTokenOutput();
668 $mut->checkMailLog([$expected]);
669
670 $tokenProcessor->addMessage('html', $this->getTokenString(array_keys($this->getParticipantTokens())), 'text/plain');
671 $tokenProcessor->addRow(['participantId' => $this->ids['participant'][0]]);
672 $tokenProcessor->evaluate();
673 $this->assertEquals($expected, $tokenProcessor->getRow(0)->render('html'));
674
675 }
676
677 /**
678 * Test that membership tokens are consistently rendered.
679 *
680 * @throws \API_Exception
681 * @throws \CRM_Core_Exception
682 */
683 public function testParticipantCustomDateToken(): void {
684 $this->createEventAndParticipant();
685 $dateFieldID = $this->createDateCustomField(['custom_group_id' => $this->ids['CustomGroup']['participant_'], 'default_value' => ''])['id'];
686 $input = '{participant.custom_' . $dateFieldID . '}';
687 $input .= '{participant.' . $this->getCustomFieldName('participant_int') . '}';
688 $tokenHtml = CRM_Core_BAO_MessageTemplate::renderTemplate([
689 'messageTemplate' => ['msg_html' => $input],
690 'tokenContext' => array_merge(['participantId' => $this->ids['participant'][0]], ['schema' => ['participantId', 'eventId']]),
691 ])['html'];
692 $this->assertEquals(99999, $tokenHtml);
693 }
694
695 /**
696 * Get declared participant tokens.
697 *
698 * @return string[]
699 */
700 public function getParticipantTokens(): array {
701 return [
702 '{participant.status_id}' => 'Status ID',
703 '{participant.role_id}' => 'Participant Role ID',
704 '{participant.register_date}' => 'Register date',
705 '{participant.source}' => 'Participant Source',
706 '{participant.fee_level}' => 'Fee level',
707 '{participant.fee_amount}' => 'Fee Amount',
708 '{participant.registered_by_id}' => 'Registered By Participant ID',
709 '{participant.transferred_to_contact_id}' => 'Transferred to Contact ID',
710 '{participant.role_id:label}' => 'Participant Role',
711 '{participant.balance}' => 'Event Balance',
712 '{participant.' . $this->getCustomFieldName('participant_int') . '}' => 'Enter integer here :: participant_Group with field int',
713 '{participant.id}' => 'Participant ID',
714 '{participant.fee_currency}' => 'Fee Currency',
715 '{participant.discount_amount}' => 'Discount Amount',
716 '{participant.status_id:label}' => 'Status',
717 '{participant.status_id:name}' => 'Machine name: Status',
718 '{participant.role_id:name}' => 'Machine name: Participant Role',
719 '{participant.is_test:label}' => 'Test',
720 '{participant.must_wait}' => 'Must Wait on List',
721 ];
722 }
723
724 /**
725 * Test that domain tokens are consistently rendered.
726 */
727 public function testDomainTokenConsistency(): void {
728 $tokens = CRM_Core_SelectValues::domainTokens();
729 $this->assertEquals($this->getDomainTokens(), $tokens);
730 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
731 'controller' => __CLASS__,
732 'smarty' => FALSE,
733 ]);
734 $tokens['{domain.id}'] = 'Domain ID';
735 $tokens['{domain.description}'] = 'Domain Description';
736 $tokens['{domain.now}'] = 'Current time/date';
737 $this->assertEquals($tokens, $tokenProcessor->listTokens());
738 }
739
740 /**
741 * @throws \API_Exception
742 * @throws \CRM_Core_Exception
743 */
744 public function testDomainNow(): void {
745 putenv('TIME_FUNC=frozen');
746 CRM_Utils_Time::setTime('2021-09-18 23:58:00');
747 $modifiers = [
748 'shortdate' => '09/18/2021',
749 '%B %Y' => 'September 2021',
750 ];
751 foreach ($modifiers as $filter => $expected) {
752 $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
753 'messageTemplate' => [
754 'msg_text' => '{domain.now|crmDate:"' . $filter . '"}',
755 ],
756 ])['text'];
757 $this->assertEquals($expected, $resolved);
758 }
759 $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
760 'messageTemplate' => [
761 'msg_text' => '{domain.now}',
762 ],
763 ])['text'];
764 $this->assertEquals('September 18th, 2021 11:58 PM', $resolved);
765
766 // This example is malformed - no quotes
767 try {
768 $resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
769 'messageTemplate' => [
770 'msg_text' => '{domain.now|crmDate:shortdate}',
771 ],
772 ])['text'];
773 $this->fail('Expected unquoted parameter to fail');
774 }
775 catch (\CRM_Core_Exception $e) {
776 $this->assertRegExp(';Malformed token param;', $e->getMessage());
777 }
778 }
779
780 /**
781 * Get declared participant tokens.
782 *
783 * @return string[]
784 */
785 public function getDomainTokens(): array {
786 return [
787 '{domain.name}' => ts('Domain name'),
788 '{domain.address}' => ts('Domain (organization) address'),
789 '{domain.phone}' => ts('Domain (organization) phone'),
790 '{domain.email}' => 'Domain (organization) email',
791 '{domain.id}' => ts('Domain ID'),
792 '{domain.description}' => ts('Domain Description'),
793 '{domain.now}' => 'Current time/date',
794 '{domain.tax_term}' => 'Sales tax term (e.g VAT)',
795 ];
796 }
797
798 /**
799 * Test that event tokens are consistently rendered.
800 *
801 * @throws \API_Exception
802 */
803 public function testEventTokenConsistency(): void {
804 $mut = new CiviMailUtils($this);
805 $this->setupParticipantScheduledReminder();
806
807 $tokens = CRM_Core_SelectValues::eventTokens();
808 $this->assertEquals(array_merge($this->getEventTokens()), $tokens);
809 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
810 'controller' => __CLASS__,
811 'smarty' => FALSE,
812 'schema' => ['eventId'],
813 ]);
814 $this->assertEquals(array_merge($tokens, $this->getDomainTokens()), $tokenProcessor->listTokens());
815
816 $expectedEventString = $this->getExpectedEventTokenOutput();
817 $this->callAPISuccess('job', 'send_reminder', []);
818 $expectedParticipantString = $this->getExpectedParticipantTokenOutput();
819 $toCheck = array_merge(explode("\n", $expectedEventString), explode("\n", $expectedParticipantString));
820 $toCheck[] = $expectedEventString;
821 $toCheck[] = $expectedParticipantString;
822 $mut->checkMailLog($toCheck);
823 $tokens = array_keys($this->getEventTokens());
824 $html = $this->getTokenString($tokens);
825 $tokenProcessor->addMessage('html', $html, 'text/plain');
826 $tokenProcessor->addRow(['eventId' => $this->ids['event'][0]]);
827 $tokenProcessor->evaluate();
828 $this->assertEquals($expectedEventString, $tokenProcessor->getRow(0)->render('html'));
829 }
830
831 /**
832 * Test that event tokens work absent participant tokens.
833 *
834 * @throws \API_Exception
835 */
836 public function testEventTokenConsistencyNoParticipantTokens(): void {
837 $mut = new CiviMailUtils($this);
838 $this->setupParticipantScheduledReminder(FALSE);
839
840 $this->callAPISuccess('job', 'send_reminder', []);
841 $expected = $this->getExpectedEventTokenOutput();
842 // Checking these individually is easier to decipher discrepancies
843 // but we also want to check in entirety.
844 $toCheck = explode("\n", $expected);
845 $toCheck[] = $expected;
846 $mut->checkMailLog($toCheck);
847
848 $tokenProcessor = new TokenProcessor(\Civi::dispatcher(), [
849 'controller' => __CLASS__,
850 'smarty' => FALSE,
851 'schema' => ['eventId'],
852 ]);
853 $html = $this->getTokenString(array_keys($this->getEventTokens()));
854
855 $tokenProcessor->addMessage('html', $html, 'text/plain');
856 $tokenProcessor->addRow(['eventId' => $this->ids['event'][0]]);
857 $tokenProcessor->evaluate();
858 $this->assertEquals($expected, $tokenProcessor->getRow(0)->render('html'));
859
860 }
861
862 /**
863 * Set up scheduled reminder for participants.
864 *
865 * @throws \API_Exception
866 */
867 public function setupParticipantScheduledReminder($includeParticipant = TRUE): void {
868 $this->createEventAndParticipant();
869 $tokens = array_keys($this->getEventTokens());
870 if ($includeParticipant) {
871 $tokens = array_keys(array_merge($this->getEventTokens(), $this->getParticipantTokens()));
872 }
873 $html = $this->getTokenString($tokens);
874 CRM_Utils_Time::setTime('2007-02-20 15:00:00');
875 $this->callAPISuccess('action_schedule', 'create', [
876 'title' => 'job',
877 'subject' => 'job',
878 'entity_value' => 1,
879 'mapping_id' => 2,
880 'start_action_date' => 'register_date',
881 'start_action_offset' => 1,
882 'start_action_condition' => 'after',
883 'start_action_unit' => 'day',
884 'body_html' => $html,
885 ]);
886 }
887
888 /**
889 * Get expected event tokens.
890 *
891 * @return string[]
892 */
893 protected function getEventTokens(): array {
894 return [
895 '{event.id}' => 'Event ID',
896 '{event.title}' => 'Event Title',
897 '{event.start_date}' => 'Event Start Date',
898 '{event.end_date}' => 'Event End Date',
899 '{event.event_tz:label}' => 'Event Time Zone',
900 '{event.event_type_id:label}' => 'Event Type',
901 '{event.summary}' => 'Event Summary',
902 '{event.contact_email}' => 'Event Contact Email',
903 '{event.contact_phone}' => 'Event Contact Phone',
904 '{event.description}' => 'Event Description',
905 '{event.location}' => 'Event Location',
906 '{event.info_url}' => 'Event Info URL',
907 '{event.registration_url}' => 'Event Registration URL',
908 '{event.pay_later_receipt}' => 'Pay Later Receipt Text',
909 '{event.' . $this->getCustomFieldName('text') . '}' => 'Enter text here :: Group with field text',
910 ];
911 }
912
913 /**
914 * @param array $tokens
915 *
916 * @return string
917 */
918 protected function getTokenString(array $tokens): string {
919 $html = '';
920 foreach ($tokens as $token) {
921 $html .= substr($token, 1, -1) . ' :' . $token . "\n";
922 }
923 return $html;
924 }
925
926 /**
927 * Create an event with a participant.
928 *
929 * @throws \API_Exception
930 */
931 protected function createEventAndParticipant(): void {
932 $this->createCustomGroupWithFieldOfType(['extends' => 'Event']);
933 $this->createCustomGroupWithFieldOfType(['extends' => 'Participant'], 'int', 'participant_');
934 $emailID = Email::create()
935 ->setValues(['email' => 'event@example.com'])
936 ->execute()
937 ->first()['id'];
938 $addressID = Address::create()->setValues([
939 'street_address' => '15 Walton St',
940 'supplemental_address_1' => 'up the road',
941 'city' => 'Emerald City',
942 'state_province_id:label' => 'Maine',
943 'postal_code' => 90210,
944 ])->execute()->first()['id'];
945 $phoneID = Phone::create()
946 ->setValues(['phone' => '456 789'])
947 ->execute()
948 ->first()['id'];
949
950 $locationBlockID = LocBlock::save(FALSE)->setRecords([
951 [
952 'email_id' => $emailID,
953 'address_id' => $addressID,
954 'phone_id' => $phoneID,
955 ],
956 ])->execute()->first()['id'];
957 $this->ids['event'][0] = $this->eventCreate([
958 'description' => 'event description',
959 $this->getCustomFieldName('text') => 'my field',
960 'loc_block_id' => $locationBlockID,
961 'event_tz' => 'America/New_York',
962 ])['id'];
963 // Create an unrelated participant record so that the ids don't match.
964 // this prevents things working just because the id 'happens to be valid'
965 $this->participantCreate([
966 'register_date' => '2020-01-01',
967 'event_id' => $this->ids['event'][0],
968 ]);
969 $this->ids['participant'][0] = $this->participantCreate([
970 'event_id' => $this->ids['event'][0],
971 'fee_amount' => 50,
972 'fee_level' => 'steep',
973 $this->getCustomFieldName('participant_int') => '99999',
974 ]);
975 }
976
977 public function testEscaping() {
978 $autoClean = [];
979 $create = function(string $entity, array $record = []) use (&$autoClean) {
980 // It's convenient to use createTestObject(), but it doesn't reproduce the normal escaping rules from QuickForm/APIv3/APIv4.
981 CRM_Utils_API_HTMLInputCoder::singleton()->encodeRow($record);
982 $dao = CRM_Core_DAO::createTestObject(CRM_Core_DAO_AllCoreTables::getFullName($entity), $record);
983
984 // We're not using transactions, and truncating 'contact' seems problematic, so we roll up our sleeves and cleanup each record...
985 $autoClean[] = CRM_Utils_AutoClean::with(function() use ($entity, $dao) {
986 CRM_Core_DAO::deleteTestObjects(CRM_Core_DAO_AllCoreTables::getFullName($entity), ['id' => $dao->id]);
987 });
988
989 return $dao;
990 };
991
992 $context = [];
993 $context['contactId'] = $create('Contact', [
994 'first_name' => '<b>ig</b>illy brackets',
995 ])->id;
996 $context['eventId'] = $create('Event', [
997 'title' => 'The Webinar',
998 'description' => '<p>Some online webinar thingy.</p> <p>Attendees will need to install the <a href="http://telefoo.example.com">TeleFoo</a> app.</p>',
999 ])->id;
1000
1001 $messages = $expected = [];
1002
1003 // The `first_name` does not allow HTML. Any funny characters are presented like literal text.
1004 $messages['contact_text'] = 'Hello {contact.first_name}!';
1005 $expected['contact_text'] = 'Hello <b>ig</b>illy brackets!';
1006
1007 $messages['contact_html'] = '<p>Hello {contact.first_name}!</p>';
1008 $expected['contact_html'] = '<p>Hello &lt;b&gt;ig&lt;/b&gt;illy brackets!</p>';
1009
1010 // The `description` does allow HTML. Any funny characters are filtered out of text.
1011 $messages['event_text'] = 'You signed up for this event: {event.title}: {event.description}';
1012 $expected['event_text'] = 'You signed up for this event: The Webinar: Some online webinar thingy. Attendees will need to install the TeleFoo app.';
1013
1014 $messages['event_html'] = '<p>You signed up for this event:</p> <h3>{event.title}</h3> {event.description}';
1015 $expected['event_html'] = '<p>You signed up for this event:</p> <h3>The Webinar</h3> <p>Some online webinar thingy.</p> <p>Attendees will need to install the <a href="http://telefoo.example.com">TeleFoo</a> app.</p>';
1016
1017 $rendered = CRM_Core_TokenSmarty::render($messages, $context);
1018
1019 $this->assertEquals($expected, $rendered);
1020 }
1021
1022 }