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