4 use Civi\Token\Event\TokenRegisterEvent
;
5 use Civi\Token\Event\TokenValueEvent
;
6 use Symfony\Component\EventDispatcher\EventDispatcher
;
8 class TokenProcessorTest
extends \CiviUnitTestCase
{
11 * @var \Symfony\Component\EventDispatcher\EventDispatcher
13 protected $dispatcher;
17 * Array(string $funcName => int $invocationCount).
21 protected function setUp(): void
{
22 $this->useTransaction(TRUE);
24 $this->dispatcher
= new EventDispatcher();
25 $this->dispatcher
->addListener('civi.token.list', [$this, 'onListTokens']);
26 $this->dispatcher
->addListener('civi.token.eval', [$this, 'onEvalTokens']);
34 * The visitTokens() method is internal - but it is important basis for other
35 * methods. Specifically, it parses all token expressions and invokes a
38 * Ensure these callbacks get the expected data (with various quirky
41 * @throws \CRM_Core_Exception
43 public function testVisitTokens(): void
{
44 $p = new TokenProcessor($this->dispatcher
, [
45 'controller' => __CLASS__
,
48 '{foo.bar}' => ['foo', 'bar', NULL],
49 '{foo.bar|whiz}' => ['foo', 'bar', ['whiz']],
50 '{foo.bar|whiz:"bang"}' => ['foo', 'bar', ['whiz', 'bang']],
51 '{FoO.bAr|whiz:"bang"}' => ['FoO', 'bAr', ['whiz', 'bang']],
52 '{oo_f.ra_b|b_52:"bang":"b@ng, on +he/([do0r])?!"}' => ['oo_f', 'ra_b', ['b_52', 'bang', 'b@ng, on +he/([do0r])?!']],
53 '{foo.bar.whiz}' => ['foo', 'bar.whiz', NULL],
54 '{foo.bar.whiz|bang}' => ['foo', 'bar.whiz', ['bang']],
55 '{foo.bar:label}' => ['foo', 'bar:label', NULL],
56 '{foo.bar:label|truncate:"10"}' => ['foo', 'bar:label', ['truncate', '10']],
58 foreach ($examples as $input => $expected) {
59 array_unshift($expected, $input);
61 $filtered = $p->visitTokens($input, function (?
string $fullToken, ?
string $entity, ?
string $field, ?
array $modifier) use (&$log) {
62 $log[] = [$fullToken, $entity, $field, $modifier];
65 $this->assertCount(1, $log, "Should receive one callback on expression: $input");
66 $this->assertEquals($expected, $log[0]);
67 $this->assertEquals('Replaced!', $filtered);
72 * Test that a row can be added via "addRow(array $context)".
74 public function testAddRow(): void
{
75 $p = new TokenProcessor($this->dispatcher
, [
76 'controller' => __CLASS__
,
78 $createdRow = $p->addRow(['one' => 'Apple'])
79 ->context('two', 'Banana');
80 $gotRow = $p->getRow(0);
81 foreach ([$createdRow, $gotRow] as $row) {
82 $this->assertEquals('Apple', $row->context
['one']);
83 $this->assertEquals('Banana', $row->context
['two']);
88 * Test that multiple rows can be added via "addRows(array $contexts)".
90 public function testAddRows(): void
{
91 $p = new TokenProcessor($this->dispatcher
, [
92 'controller' => __CLASS__
,
94 $createdRows = $p->addRows([
95 ['one' => 'Apple', 'two' => 'Banana'],
96 ['one' => 'Pomme', 'two' => 'Banane'],
98 $gotRow0 = $p->getRow(0);
99 foreach ([$createdRows[0], $gotRow0] as $row) {
100 $this->assertEquals('Apple', $row->context
['one']);
101 $this->assertEquals('Banana', $row->context
['two']);
103 $gotRow1 = $p->getRow(1);
104 foreach ([$createdRows[1], $gotRow1] as $row) {
105 $this->assertEquals('Pomme', $row->context
['one']);
106 $this->assertEquals('Banane', $row->context
['two']);
111 * Check that the TokenRow helper can correctly read/update context
114 public function testRowContext(): void
{
115 $p = new TokenProcessor($this->dispatcher
, [
116 'controller' => __CLASS__
,
119 $createdRow = $p->addRow()
121 ->context('two', [2 => 3])
127 $gotRow = $p->getRow(0);
128 foreach ([$createdRow, $gotRow] as $row) {
129 $this->assertEquals(1, $row->context
['one']);
130 $this->assertEquals(3, $row->context
['two'][2]);
131 $this->assertEquals(5, $row->context
['two'][4]);
132 $this->assertEquals(7, $row->context
['three'][6]);
133 $this->assertEquals(98, $row->context
['omega']);
134 $this->assertEquals(__CLASS__
, $row->context
['controller']);
139 * Check that getContextValues() returns the correct data
141 public function testGetContextValues(): void
{
142 $p = new TokenProcessor($this->dispatcher
, [
143 'controller' => __CLASS__
,
146 $p->addRow()->context('id', 10)->context('omega', '98');
147 $p->addRow()->context('id', 10)->context('contact', (object) ['cid' => 10]);
148 $p->addRow()->context('id', 11)->context('contact', (object) ['cid' => 11]);
149 $this->assertArrayValuesEqual([10, 11], $p->getContextValues('id'));
150 $this->assertArrayValuesEqual(['99', '98'], $p->getContextValues('omega'));
151 $this->assertArrayValuesEqual([10, 11], $p->getContextValues('contact', 'cid'));
155 * Check that the TokenRow helper can correctly read/update token
158 public function testRowTokens(): void
{
159 $p = new TokenProcessor($this->dispatcher
, [
160 'controller' => __CLASS__
,
162 $createdRow = $p->addRow()
164 ->tokens('two', [2 => 3])
169 ->tokens('four', 8, 9);
170 $gotRow = $p->getRow(0);
171 foreach ([$createdRow, $gotRow] as $row) {
172 $this->assertEquals(1, $row->tokens
['one']);
173 $this->assertEquals(3, $row->tokens
['two'][2]);
174 $this->assertEquals(5, $row->tokens
['two'][4]);
175 $this->assertEquals(7, $row->tokens
['three'][6]);
176 $this->assertEquals(9, $row->tokens
['four'][8]);
180 public function testRenderLocalizedSmarty() {
181 \CRM_Utils_Time
::setTime('2022-04-08 16:32:04');
182 $resetTime = \CRM_Utils_AutoClean
::with(['CRM_Utils_Time', 'resetTime']);
183 $this->dispatcher
->addSubscriber(new \
CRM_Core_DomainTokens());
184 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
185 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
186 $p = new TokenProcessor($this->dispatcher
, [
187 'controller' => __CLASS__
,
190 $p->addMessage('text', '{ts}Yes{/ts} {ts}No{/ts} {domain.now|crmDate:"%B"}', 'text/plain');
192 $p->addRow(['locale' => 'fr_FR']);
193 $p->addRow(['locale' => 'es_MX']);
202 foreach ($p->evaluate()->getRows() as $key => $row) {
204 $this->assertTrue($row instanceof TokenRow
);
205 $this->assertEquals($expectText[$key], $row->render('text'));
208 $this->assertEquals(3, $rowCount);
211 public function testRenderLocalizedHookToken(): void
{
212 $cid = $this->individualCreate();
214 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
215 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
216 \Civi
::dispatcher()->addListener('hook_civicrm_tokens', function($e) {
217 $e->tokens
['trans'] = [
218 'trans.affirm' => ts('Translated affirmation'),
221 \Civi
::dispatcher()->addListener('hook_civicrm_tokenValues', function($e) {
222 if (in_array('affirm', $e->tokens
['trans'], TRUE)) {
223 foreach ($e->contactIDs
as $cid) {
224 $e->details
[$cid]['trans.affirm'] = ts('Yes');
229 unset(\Civi
::$statics['CRM_Contact_Tokens']['hook_tokens']);
230 $tokenProcessor = new TokenProcessor($this->dispatcher
, [
231 'controller' => __CLASS__
,
234 $tokenProcessor->addMessage('text', '!!{trans.affirm}!!', 'text/plain');
235 $tokenProcessor->addRow(['contactId' => $cid]);
236 $tokenProcessor->addRow(['contactId' => $cid, 'locale' => 'fr_FR']);
237 $tokenProcessor->addRow(['contactId' => $cid, 'locale' => 'es_MX']);
246 foreach ($tokenProcessor->evaluate()->getRows() as $key => $row) {
248 $this->assertTrue($row instanceof TokenRow
);
249 $this->assertEquals($expectText[$key], $row->render('text'));
252 $this->assertEquals(3, $rowCount);
255 public function testGetMessageTokens() {
256 $p = new TokenProcessor($this->dispatcher
, [
257 'controller' => __CLASS__
,
259 $p->addMessage('greeting_html', 'Good morning, <p>{contact.display_name}</p>. {custom.foobar}!', 'text/html');
260 $p->addMessage('greeting_text', 'Good morning, {contact.display_name}. {custom.whizbang}, {contact.first_name}!', 'text/plain');
262 'contact' => ['display_name', 'first_name'],
263 'custom' => ['foobar', 'whizbang'],
265 $this->assertEquals($expected, $p->getMessageTokens());
268 public function testListTokens(): void
{
269 $p = new TokenProcessor($this->dispatcher
, [
270 'controller' => __CLASS__
,
272 $p->addToken(['entity' => 'MyEntity', 'field' => 'myField', 'label' => 'My Label']);
273 $this->assertEquals(['{MyEntity.myField}' => 'My Label'], $p->listTokens());
277 * Perform a full mail-merge, substituting multiple tokens for multiple
278 * contacts in multiple messages.
280 public function testFull() {
281 $p = new TokenProcessor($this->dispatcher
, [
282 'controller' => __CLASS__
,
284 $p->addMessage('greeting_html', 'Good morning, <p>{contact.display_name}</p>. {custom.foobar} Bye!', 'text/html');
285 $p->addMessage('greeting_text', 'Good morning, {contact.display_name}. {custom.foobar} Bye!', 'text/plain');
287 ->context(['contact_id' => 123])
288 ->format('text/plain')->tokens([
289 'contact' => ['display_name' => 'What'],
292 ->context(['contact_id' => 4])
293 ->format('text/plain')->tokens([
294 'contact' => ['display_name' => 'Who'],
297 ->context(['contact_id' => 10])
298 ->format('text/plain')->tokens([
299 'contact' => ['display_name' => 'Darth Vader'],
303 0 => 'Good morning, <p>What</p>. #0123 is a good number. Trickster {contact.display_name}. Bye!',
304 1 => 'Good morning, <p>Who</p>. #0004 is a good number. Trickster {contact.display_name}. Bye!',
305 2 => 'Good morning, <p>Darth Vader</p>. #0010 is a good number. Trickster {contact.display_name}. Bye!',
309 0 => 'Good morning, What. #0123 is a good number. Trickster {contact.display_name}. Bye!',
310 1 => 'Good morning, Who. #0004 is a good number. Trickster {contact.display_name}. Bye!',
311 2 => 'Good morning, Darth Vader. #0010 is a good number. Trickster {contact.display_name}. Bye!',
315 foreach ($p->evaluate()->getRows() as $key => $row) {
317 $this->assertTrue($row instanceof TokenRow
);
318 $this->assertEquals($expectHtml[$key], $row->render('greeting_html'));
319 $this->assertEquals($expectText[$key], $row->render('greeting_text'));
322 $this->assertEquals(3, $rowCount);
323 // This may change in the future.
324 $this->assertEquals(0, $this->counts
['onListTokens']);
325 $this->assertEquals(1, $this->counts
['onEvalTokens']);
328 public function testFilter() {
329 $exampleTokens['foo_bar']['whiz_bang'] = 'Some Text';
331 'This is {foo_bar.whiz_bang}.' => 'This is Some Text.',
332 'This is {foo_bar.whiz_bang|lower}...' => 'This is some text...',
333 'This is {foo_bar.whiz_bang|upper}!' => 'This is SOME TEXT!',
335 $expectExampleCount = /* {#msgs} x {smarty:on,off} */ 6;
336 $actualExampleCount = 0;
338 foreach ($exampleMessages as $inputMessage => $expectOutput) {
339 foreach ([TRUE, FALSE] as $useSmarty) {
340 $p = new TokenProcessor($this->dispatcher
, [
341 'controller' => __CLASS__
,
342 'smarty' => $useSmarty,
344 $p->addMessage('example', $inputMessage, 'text/plain');
346 ->format('text/plain')->tokens($exampleTokens);
347 foreach ($p->evaluate()->getRows() as $key => $row) {
348 $this->assertEquals($expectOutput, $row->render('example'));
349 $actualExampleCount++
;
354 $this->assertEquals($expectExampleCount, $actualExampleCount);
357 public function onListTokens(TokenRegisterEvent
$e) {
358 $this->counts
[__FUNCTION__
]++
;
359 $e->register('custom', [
360 'foobar' => 'A special message about foobar',
364 public function onEvalTokens(TokenValueEvent
$e) {
365 $this->counts
[__FUNCTION__
]++
;
366 foreach ($e->getRows() as $row) {
367 /** @var TokenRow $row */
368 $row->format('text/html');
369 $row->tokens
['custom']['foobar'] = sprintf("#%04d is a good number. Trickster {contact.display_name}.", $row->context
['contact_id']);
374 * Inspired by dev/core#2673. This creates three custom tokens and uses each
375 * of them in a different template (subject/body_text/body_html). Ensure
376 * that all 3 tokens are properly evaluated.
378 * This is not literally the same as dev/core#2673. But that class of problem
379 * could arise in different code-paths. This just ensures that it arise in
382 * It also improves test-coverage of hooks and TokenProcessor.
384 * @link https://lab.civicrm.org/dev/core/-/issues/2673
386 public function testHookTokenDiagonal() {
387 $cid = $this->individualCreate();
389 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
390 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
392 \Civi
::dispatcher()->addListener('hook_civicrm_tokens', function($e) {
393 $e->tokens
['fruit'] = [
394 'fruit.apple' => ts('Apple'),
395 'fruit.banana' => ts('Banana'),
396 'fruit.cherry' => ts('Cherry'),
399 \Civi
::dispatcher()->addListener('hook_civicrm_tokenValues', function($e) {
400 $fruits = array_intersect($e->tokens
['fruit'], ['apple', 'banana', 'cherry']);
401 foreach ($fruits as $fruit) {
402 foreach ($e->contactIDs
as $cid) {
403 $e->details
[$cid]['fruit.' . $fruit] = 'Nomnomnom' . $fruit;
408 unset(\Civi
::$statics['CRM_Contact_Tokens']['hook_tokens']);
409 $tokenProcessor = new TokenProcessor($this->dispatcher
, [
410 'controller' => __CLASS__
,
413 $tokenProcessor->addMessage('subject', '!!{fruit.apple}!!', 'text/plain');
414 $tokenProcessor->addMessage('body_html', '!!{fruit.banana}!!', 'text/html');
415 $tokenProcessor->addMessage('body_text', '!!{fruit.cherry}!!', 'text/plain');
416 $tokenProcessor->addMessage('other', 'No fruit :(', 'text/plain');
417 $tokenProcessor->addRow(['contactId' => $cid]);
418 $tokenProcessor->evaluate();
420 foreach ($tokenProcessor->getRows() as $row) {
421 $this->assertEquals('!!Nomnomnomapple!!', $row->render('subject'));
422 $this->assertEquals('!!Nomnomnombanana!!', $row->render('body_html'));
423 $this->assertEquals('!!Nomnomnomcherry!!', $row->render('body_text'));
424 $this->assertEquals('No fruit :(', $row->render('other'));
427 $this->assertTrue(isset($looped));
431 * Define extended tokens with funny symbols
433 public function testHookTokenExtraChar(): void
{
434 $cid = $this->individualCreate();
436 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
437 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
438 \Civi
::dispatcher()->addListener('hook_civicrm_tokens', function ($e) {
439 $e->tokens
['food'] = [
440 'food.fruit.apple' => ts('Apple'),
441 'food.fruit:summary' => ts('Fruit summary'),
444 \Civi
::dispatcher()->addListener('hook_civicrm_tokenValues', function ($e) {
445 foreach ($e->tokens
['food'] ??
[] as $subtoken) {
446 foreach ($e->contactIDs
as $cid) {
449 $e->details
[$cid]['food.fruit.apple'] = 'Fruit of the Tree';
452 case 'fruit:summary':
453 $e->details
[$cid]['food.fruit:summary'] = 'Apples, Bananas, and Cherries Oh My';
459 unset(\Civi
::$statics['CRM_Contact_Tokens']['hook_tokens']);
460 $expectRealSmartyOutputs = [
461 TRUE => 'Fruit of the Tree yes',
462 FALSE => 'Fruit of the Tree {if 1}yes{else}no{/if}',
466 foreach ([TRUE, FALSE] as $smarty) {
467 $tokenProcessor = new TokenProcessor($this->dispatcher
, [
468 'controller' => __CLASS__
,
471 $tokenProcessor->addMessage('real_dot', '!!{food.fruit.apple}!!', 'text/plain');
472 $tokenProcessor->addMessage('real_dot_smarty', '{food.fruit.apple} {if 1}yes{else}no{/if}', 'text/plain');
473 $tokenProcessor->addMessage('real_colon', 'Summary of fruits: {food.fruit:summary}!', 'text/plain');
474 $tokenProcessor->addMessage('not_real_1', '!!{food.fruit}!!', 'text/plain');
475 $tokenProcessor->addMessage('not_real_2', '!!{food.apple}!!', 'text/plain');
476 $tokenProcessor->addMessage('not_real_3', '!!{fruit.apple}!!', 'text/plain');
477 $tokenProcessor->addMessage('not_real_4', '!!{food.fruit:apple}!!', 'text/plain');
478 $tokenProcessor->addRow(['contactId' => $cid]);
479 $tokenProcessor->evaluate();
481 foreach ($tokenProcessor->getRows() as $row) {
483 $this->assertEquals('!!Fruit of the Tree!!', $row->render('real_dot'));
484 $this->assertEquals($expectRealSmartyOutputs[$smarty], $row->render('real_dot_smarty'));
485 $this->assertEquals('Summary of fruits: Apples, Bananas, and Cherries Oh My!', $row->render('real_colon'));
486 $this->assertEquals('!!!!', $row->render('not_real_1'));
487 $this->assertEquals('!!!!', $row->render('not_real_2'));
488 $this->assertEquals('!!!!', $row->render('not_real_3'));
489 $this->assertEquals('!!!!', $row->render('not_real_4'));
492 $this->assertEquals(2, $loops);
496 * Process a message using mocked data.
498 public function testMockData_ContactContribution(): void
{
499 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
500 $this->dispatcher
->addSubscriber(new \
CRM_Contribute_Tokens());
501 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
502 $p = new TokenProcessor($this->dispatcher
, [
503 'controller' => __CLASS__
,
504 'schema' => ['contributionId', 'contactId'],
506 $p->addMessage('example', 'Invoice #{contribution.invoice_id} for {contact.display_name}!', 'text/plain');
510 'display_name' => 'The Override',
512 'contributionId' => 111,
515 'receive_date' => '2012-01-02',
516 'invoice_id' => 11111,
522 'display_name' => 'Another Override',
524 'contributionId' => 222,
527 'receive_date' => '2012-01-02',
528 'invoice_id' => 22222,
534 foreach ($p->getRows() as $row) {
535 $outputs[] = $row->render('example');
537 $this->assertEquals('Invoice #11111 for The Override!', $outputs[0]);
538 $this->assertEquals('Invoice #22222 for Another Override!', $outputs[1]);
542 * Process a message using mocked data, accessed through a Smarty alias.
544 public function testMockData_SmartyAlias_Contribution(): void
{
545 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
546 $this->dispatcher
->addSubscriber(new \
CRM_Contribute_Tokens());
547 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
549 $p = new TokenProcessor($this->dispatcher
, [
550 'controller' => __CLASS__
,
551 'schema' => ['contributionId'],
553 'smartyTokenAlias' => [
554 'theInvoiceId' => 'contribution.invoice_id',
557 $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain');
559 'contributionId' => 333,
562 'receive_date' => '2012-01-02',
563 'invoice_id' => 33333,
567 'contributionId' => 444,
570 'receive_date' => '2012-01-02',
571 'invoice_id' => 44444,
577 foreach ($p->getRows() as $row) {
578 $outputs[] = $row->render('example');
580 $this->assertEquals('Invoice #33333!', $outputs[0]);
581 $this->assertEquals('Invoice #44444!', $outputs[1]);
586 * This defines a compatibility mechanism wherein an old Smarty expression can
587 * be evaluated based on a newer token expression.
589 * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field';
591 public function testSmartyTokenAlias_Contribution(): void
{
592 $first = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2010-01-01', 'invoice_id' => 100, 'trxn_id' => 1000]);
593 $second = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2011-02-02', 'invoice_id' => 200, 'trxn_id' => 1]);
594 $this->dispatcher
->addSubscriber(new TokenCompatSubscriber());
595 $this->dispatcher
->addSubscriber(new \
CRM_Contribute_Tokens());
596 $this->dispatcher
->addSubscriber(new \
CRM_Contact_Tokens());
598 $p = new TokenProcessor($this->dispatcher
, [
599 'controller' => __CLASS__
,
600 'schema' => ['contributionId'],
602 'smartyTokenAlias' => [
603 'theInvoiceId' => 'contribution.invoice_id',
606 $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain');
607 $p->addRow(['contributionId' => $first]);
608 $p->addRow(['contributionId' => $second]);
612 foreach ($p->getRows() as $row) {
613 $outputs[] = $row->render('example');
615 $this->assertEquals('Invoice #100!', $outputs[0]);
616 $this->assertEquals('Invoice #200!', $outputs[1]);
620 // * This defines a compatibility mechanism wherein an old Smarty expression can
621 // * be evaluated based on a newer token expression.
623 // * The following example doesn't work because the handling of greeting+contact
624 // * tokens still use a special override (TokenCompatSubscriber::onRender).
626 // * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field';
628 // public function testSmartyTokenAlias_Contact() {
629 // $alice = $this->individualCreate(['first_name' => 'Alice']);
630 // $bob = $this->individualCreate(['first_name' => 'Bob']);
631 // $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
633 // $p = new TokenProcessor($this->dispatcher, [
634 // 'controller' => __CLASS__,
635 // 'schema' => ['contactId'],
637 // 'smartyTokenAlias' => [
638 // 'myFirstName' => 'contact.first_name',
641 // $p->addMessage('example', 'Hello {$myFirstName}!', 'text/plain');
642 // $p->addRow(['contactId' => $alice]);
643 // $p->addRow(['contactId' => $bob]);
647 // foreach ($p->getRows() as $row) {
648 // $outputs[] = $row->render('example');
650 // $this->assertEquals('Hello Alice!', $outputs[0]);
651 // $this->assertEquals('Hello Bob!', $outputs[1]);