plugin.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. /* tslint:disable:no-non-null-assertion */
  2. import { Test, TestingModule } from '@nestjs/testing';
  3. import { TypeOrmModule } from '@nestjs/typeorm';
  4. import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
  5. import {
  6. EventBus,
  7. LanguageCode,
  8. Logger,
  9. Order,
  10. OrderStateTransitionEvent,
  11. PluginCommonModule,
  12. ProcessContextModule,
  13. RequestContext,
  14. VendureEvent,
  15. } from '@vendure/core';
  16. import { TestingLogger } from '@vendure/testing';
  17. import path from 'path';
  18. import { orderConfirmationHandler } from './default-email-handlers';
  19. import { EmailEventHandler } from './event-handler';
  20. import { EmailEventListener } from './event-listener';
  21. import { EmailPlugin } from './plugin';
  22. import { EmailPluginOptions } from './types';
  23. describe('EmailPlugin', () => {
  24. let eventBus: EventBus;
  25. let onSend: jest.Mock;
  26. let module: TestingModule;
  27. const testingLogger = new TestingLogger(() => jest.fn());
  28. async function initPluginWithHandlers(
  29. handlers: Array<EmailEventHandler<string, any>>,
  30. options?: Partial<EmailPluginOptions>,
  31. ) {
  32. onSend = jest.fn();
  33. module = await Test.createTestingModule({
  34. imports: [
  35. TypeOrmModule.forRoot({
  36. type: 'sqljs',
  37. retryAttempts: 0,
  38. }),
  39. ProcessContextModule.forRoot(),
  40. PluginCommonModule,
  41. EmailPlugin.init({
  42. templatePath: path.join(__dirname, '../test-templates'),
  43. transport: {
  44. type: 'testing',
  45. onSend,
  46. },
  47. handlers,
  48. ...options,
  49. }),
  50. ],
  51. providers: [MockService],
  52. }).compile();
  53. Logger.useLogger(testingLogger);
  54. module.useLogger(new Logger());
  55. const plugin = module.get(EmailPlugin);
  56. eventBus = module.get(EventBus);
  57. await plugin.onVendureBootstrap();
  58. return module;
  59. }
  60. afterEach(async () => {
  61. if (module) {
  62. await module.close();
  63. }
  64. });
  65. it('setting from, recipient, subject', async () => {
  66. const ctx = RequestContext.deserialize({
  67. _channel: { code: DEFAULT_CHANNEL_CODE },
  68. _languageCode: LanguageCode.en,
  69. } as any);
  70. const handler = new EmailEventListener('test')
  71. .on(MockEvent)
  72. .setFrom('"test from" <noreply@test.com>')
  73. .setRecipient(() => 'test@test.com')
  74. .setSubject('Hello')
  75. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  76. await initPluginWithHandlers([handler]);
  77. eventBus.publish(new MockEvent(ctx, true));
  78. await pause();
  79. expect(onSend.mock.calls[0][0].subject).toBe('Hello');
  80. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  81. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  82. });
  83. describe('event filtering', () => {
  84. const ctx = RequestContext.deserialize({
  85. _channel: { code: DEFAULT_CHANNEL_CODE },
  86. _languageCode: LanguageCode.en,
  87. } as any);
  88. it('single filter', async () => {
  89. const handler = new EmailEventListener('test')
  90. .on(MockEvent)
  91. .filter(event => event.shouldSend === true)
  92. .setRecipient(() => 'test@test.com')
  93. .setFrom('"test from" <noreply@test.com>')
  94. .setSubject('test subject');
  95. await initPluginWithHandlers([handler]);
  96. eventBus.publish(new MockEvent(ctx, false));
  97. await pause();
  98. expect(onSend).not.toHaveBeenCalled();
  99. eventBus.publish(new MockEvent(ctx, true));
  100. await pause();
  101. expect(onSend).toHaveBeenCalledTimes(1);
  102. });
  103. it('multiple filters', async () => {
  104. const handler = new EmailEventListener('test')
  105. .on(MockEvent)
  106. .filter(event => event.shouldSend === true)
  107. .filter(event => !!event.ctx.activeUserId)
  108. .setFrom('"test from" <noreply@test.com>')
  109. .setRecipient(() => 'test@test.com')
  110. .setSubject('test subject');
  111. await initPluginWithHandlers([handler]);
  112. eventBus.publish(new MockEvent(ctx, true));
  113. await pause();
  114. expect(onSend).not.toHaveBeenCalled();
  115. const ctxWithUser = RequestContext.deserialize({ ...ctx, _session: { user: { id: 42 } } } as any);
  116. eventBus.publish(new MockEvent(ctxWithUser, true));
  117. await pause();
  118. expect(onSend).toHaveBeenCalledTimes(1);
  119. });
  120. it('with .loadData() after .filter()', async () => {
  121. const handler = new EmailEventListener('test')
  122. .on(MockEvent)
  123. .filter(event => event.shouldSend === true)
  124. .loadData(context => Promise.resolve('loaded data'))
  125. .setRecipient(() => 'test@test.com')
  126. .setFrom('"test from" <noreply@test.com>')
  127. .setSubject('test subject');
  128. await initPluginWithHandlers([handler]);
  129. eventBus.publish(new MockEvent(ctx, false));
  130. await pause();
  131. expect(onSend).not.toHaveBeenCalled();
  132. eventBus.publish(new MockEvent(ctx, true));
  133. await pause();
  134. expect(onSend).toHaveBeenCalledTimes(1);
  135. });
  136. });
  137. describe('templateVars', () => {
  138. const ctx = RequestContext.deserialize({
  139. _channel: { code: DEFAULT_CHANNEL_CODE },
  140. _languageCode: LanguageCode.en,
  141. } as any);
  142. it('interpolates subject', async () => {
  143. const handler = new EmailEventListener('test')
  144. .on(MockEvent)
  145. .setFrom('"test from" <noreply@test.com>')
  146. .setRecipient(() => 'test@test.com')
  147. .setSubject('Hello {{ subjectVar }}')
  148. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  149. await initPluginWithHandlers([handler]);
  150. eventBus.publish(new MockEvent(ctx, true));
  151. await pause();
  152. expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
  153. });
  154. it('interpolates body', async () => {
  155. const handler = new EmailEventListener('test')
  156. .on(MockEvent)
  157. .setFrom('"test from" <noreply@test.com>')
  158. .setRecipient(() => 'test@test.com')
  159. .setSubject('Hello')
  160. .setTemplateVars(event => ({ testVar: 'this is the test var' }));
  161. await initPluginWithHandlers([handler]);
  162. eventBus.publish(new MockEvent(ctx, true));
  163. await pause();
  164. expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
  165. });
  166. /**
  167. * Intended to test the ability for Handlebars to interpolate
  168. * getters on the Order entity prototype.
  169. * See https://github.com/vendure-ecommerce/vendure/issues/259
  170. */
  171. it('interpolates body with property from entity', async () => {
  172. const handler = new EmailEventListener('test')
  173. .on(MockEvent)
  174. .setFrom('"test from" <noreply@test.com>')
  175. .setRecipient(() => 'test@test.com')
  176. .setSubject('Hello')
  177. .setTemplateVars(event => ({ order: new Order({ subTotal: 123 }) }));
  178. await initPluginWithHandlers([handler]);
  179. eventBus.publish(new MockEvent(ctx, true));
  180. await pause();
  181. expect(onSend.mock.calls[0][0].body).toContain('Total: 123');
  182. });
  183. it('interpolates globalTemplateVars', async () => {
  184. const handler = new EmailEventListener('test')
  185. .on(MockEvent)
  186. .setFrom('"test from" <noreply@test.com>')
  187. .setRecipient(() => 'test@test.com')
  188. .setSubject('Hello {{ globalVar }}');
  189. await initPluginWithHandlers([handler], {
  190. globalTemplateVars: { globalVar: 'baz' },
  191. });
  192. eventBus.publish(new MockEvent(ctx, true));
  193. await pause();
  194. expect(onSend.mock.calls[0][0].subject).toBe('Hello baz');
  195. });
  196. it('interpolates from', async () => {
  197. const handler = new EmailEventListener('test')
  198. .on(MockEvent)
  199. .setFrom('"test from {{ globalVar }}" <noreply@test.com>')
  200. .setRecipient(() => 'test@test.com')
  201. .setSubject('Hello');
  202. await initPluginWithHandlers([handler], {
  203. globalTemplateVars: { globalVar: 'baz' },
  204. });
  205. eventBus.publish(new MockEvent(ctx, true));
  206. await pause();
  207. expect(onSend.mock.calls[0][0].from).toBe('"test from baz" <noreply@test.com>');
  208. });
  209. // Test fix for https://github.com/vendure-ecommerce/vendure/issues/363
  210. it('does not escape HTML chars when interpolating "from"', async () => {
  211. const handler = new EmailEventListener('test')
  212. .on(MockEvent)
  213. .setFrom('{{ globalFrom }}')
  214. .setRecipient(() => 'Test <test@test.com>')
  215. .setSubject('Hello');
  216. await initPluginWithHandlers([handler], {
  217. globalTemplateVars: { globalFrom: 'Test <test@test.com>' },
  218. });
  219. eventBus.publish(new MockEvent(ctx, true));
  220. await pause();
  221. expect(onSend.mock.calls[0][0].from).toBe('Test <test@test.com>');
  222. });
  223. it('globalTemplateVars available in setTemplateVars method', async () => {
  224. const handler = new EmailEventListener('test')
  225. .on(MockEvent)
  226. .setFrom('"test from" <noreply@test.com>')
  227. .setRecipient(() => 'test@test.com')
  228. .setSubject('Hello {{ testVar }}')
  229. .setTemplateVars((event, globals) => ({ testVar: globals.globalVar + ' quux' }));
  230. await initPluginWithHandlers([handler], {
  231. globalTemplateVars: { globalVar: 'baz' },
  232. });
  233. eventBus.publish(new MockEvent(ctx, true));
  234. await pause();
  235. expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
  236. });
  237. it('setTemplateVars overrides globals', async () => {
  238. const handler = new EmailEventListener('test')
  239. .on(MockEvent)
  240. .setFrom('"test from" <noreply@test.com>')
  241. .setRecipient(() => 'test@test.com')
  242. .setSubject('Hello {{ name }}')
  243. .setTemplateVars((event, globals) => ({ name: 'quux' }));
  244. await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
  245. eventBus.publish(new MockEvent(ctx, true));
  246. await pause();
  247. expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
  248. });
  249. });
  250. describe('handlebars helpers', () => {
  251. const ctx = RequestContext.deserialize({
  252. _channel: { code: DEFAULT_CHANNEL_CODE },
  253. _languageCode: LanguageCode.en,
  254. } as any);
  255. it('formateDate', async () => {
  256. const handler = new EmailEventListener('test-helpers')
  257. .on(MockEvent)
  258. .setFrom('"test from" <noreply@test.com>')
  259. .setRecipient(() => 'test@test.com')
  260. .setSubject('Hello')
  261. .setTemplateVars(event => ({ myDate: new Date('2020-01-01T10:00:00.000Z'), myPrice: 0 }));
  262. await initPluginWithHandlers([handler]);
  263. eventBus.publish(new MockEvent(ctx, true));
  264. await pause();
  265. expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
  266. });
  267. it('formateMoney', async () => {
  268. const handler = new EmailEventListener('test-helpers')
  269. .on(MockEvent)
  270. .setFrom('"test from" <noreply@test.com>')
  271. .setRecipient(() => 'test@test.com')
  272. .setSubject('Hello')
  273. .setTemplateVars(event => ({ myDate: new Date(), myPrice: 123 }));
  274. await initPluginWithHandlers([handler]);
  275. eventBus.publish(new MockEvent(ctx, true));
  276. await pause();
  277. expect(onSend.mock.calls[0][0].body).toContain('Price: 1.23');
  278. });
  279. });
  280. describe('multiple configs', () => {
  281. const ctx = RequestContext.deserialize({
  282. _channel: { code: DEFAULT_CHANNEL_CODE },
  283. _languageCode: LanguageCode.en,
  284. } as any);
  285. it('additional LanguageCode', async () => {
  286. const handler = new EmailEventListener('test')
  287. .on(MockEvent)
  288. .setFrom('"test from" <noreply@test.com>')
  289. .setSubject('Hello, {{ name }}!')
  290. .setRecipient(() => 'test@test.com')
  291. .setTemplateVars(() => ({ name: 'Test' }))
  292. .addTemplate({
  293. channelCode: 'default',
  294. languageCode: LanguageCode.de,
  295. templateFile: 'body.de.hbs',
  296. subject: 'Servus, {{ name }}!',
  297. });
  298. await initPluginWithHandlers([handler]);
  299. const ctxTa = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.ta } as any);
  300. eventBus.publish(new MockEvent(ctxTa, true));
  301. await pause();
  302. expect(onSend.mock.calls[0][0].subject).toBe('Hello, Test!');
  303. expect(onSend.mock.calls[0][0].body).toContain('Default body.');
  304. const ctxDe = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.de } as any);
  305. eventBus.publish(new MockEvent(ctxDe, true));
  306. await pause();
  307. expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
  308. expect(onSend.mock.calls[1][0].body).toContain('German body.');
  309. });
  310. });
  311. describe('loadData', () => {
  312. it('loads async data', async () => {
  313. const handler = new EmailEventListener('test')
  314. .on(MockEvent)
  315. .loadData(async ({ injector }) => {
  316. const service = injector.get(MockService);
  317. return service.someAsyncMethod();
  318. })
  319. .setFrom('"test from" <noreply@test.com>')
  320. .setSubject('Hello, {{ testData }}!')
  321. .setRecipient(() => 'test@test.com')
  322. .setTemplateVars(event => ({ testData: event.data }));
  323. await initPluginWithHandlers([handler]);
  324. eventBus.publish(
  325. new MockEvent(
  326. RequestContext.deserialize({
  327. _channel: { code: DEFAULT_CHANNEL_CODE },
  328. _languageCode: LanguageCode.en,
  329. } as any),
  330. true,
  331. ),
  332. );
  333. await pause();
  334. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  335. });
  336. it('works when loadData is called after other setup', async () => {
  337. const handler = new EmailEventListener('test')
  338. .on(MockEvent)
  339. .setFrom('"test from" <noreply@test.com>')
  340. .setSubject('Hello, {{ testData }}!')
  341. .setRecipient(() => 'test@test.com')
  342. .loadData(async ({ injector }) => {
  343. const service = injector.get(MockService);
  344. return service.someAsyncMethod();
  345. })
  346. .setTemplateVars(event => ({ testData: event.data }));
  347. await initPluginWithHandlers([handler]);
  348. eventBus.publish(
  349. new MockEvent(
  350. RequestContext.deserialize({
  351. _channel: { code: DEFAULT_CHANNEL_CODE },
  352. _languageCode: LanguageCode.en,
  353. } as any),
  354. true,
  355. ),
  356. );
  357. await pause();
  358. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  359. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  360. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  361. });
  362. it('only executes for filtered events', async () => {
  363. let callCount = 0;
  364. const handler = new EmailEventListener('test')
  365. .on(MockEvent)
  366. .filter(event => event.shouldSend === true)
  367. .loadData(async ({ injector }) => {
  368. callCount++;
  369. });
  370. await initPluginWithHandlers([handler]);
  371. eventBus.publish(new MockEvent(RequestContext.empty(), false));
  372. eventBus.publish(new MockEvent(RequestContext.empty(), true));
  373. await pause();
  374. expect(callCount).toBe(1);
  375. });
  376. });
  377. describe('orderConfirmationHandler', () => {
  378. beforeEach(async () => {
  379. await initPluginWithHandlers([orderConfirmationHandler], {
  380. templatePath: path.join(__dirname, '../templates'),
  381. });
  382. });
  383. const ctx = RequestContext.deserialize({
  384. _channel: { code: DEFAULT_CHANNEL_CODE },
  385. _languageCode: LanguageCode.en,
  386. } as any);
  387. const order = ({
  388. code: 'ABCDE',
  389. customer: {
  390. emailAddress: 'test@test.com',
  391. },
  392. } as Partial<Order>) as any;
  393. it('filters events with wrong order state', async () => {
  394. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order));
  395. await pause();
  396. expect(onSend).not.toHaveBeenCalled();
  397. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'Cancelled', ctx, order));
  398. await pause();
  399. expect(onSend).not.toHaveBeenCalled();
  400. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'PaymentAuthorized', ctx, order));
  401. await pause();
  402. expect(onSend).not.toHaveBeenCalled();
  403. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  404. await pause();
  405. expect(onSend).toHaveBeenCalledTimes(1);
  406. });
  407. it('sets the Order Customer emailAddress as recipient', async () => {
  408. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  409. await pause();
  410. expect(onSend.mock.calls[0][0].recipient).toBe(order.customer!.emailAddress);
  411. });
  412. it('sets the subject', async () => {
  413. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  414. await pause();
  415. expect(onSend.mock.calls[0][0].subject).toBe(`Order confirmation for #${order.code}`);
  416. });
  417. });
  418. describe('error handling', () => {
  419. it('Logs an error if the template file is not found', async () => {
  420. const ctx = RequestContext.deserialize({
  421. _channel: { code: DEFAULT_CHANNEL_CODE },
  422. _languageCode: LanguageCode.en,
  423. } as any);
  424. testingLogger.errorSpy.mockClear();
  425. const handler = new EmailEventListener('no-template')
  426. .on(MockEvent)
  427. .setFrom('"test from" <noreply@test.com>')
  428. .setRecipient(() => 'test@test.com')
  429. .setSubject('Hello {{ subjectVar }}');
  430. await initPluginWithHandlers([handler]);
  431. eventBus.publish(new MockEvent(ctx, true));
  432. await pause();
  433. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain(`ENOENT: no such file or directory`);
  434. });
  435. it('Logs a Handlebars error if the template is invalid', async () => {
  436. const ctx = RequestContext.deserialize({
  437. _channel: { code: DEFAULT_CHANNEL_CODE },
  438. _languageCode: LanguageCode.en,
  439. } as any);
  440. testingLogger.errorSpy.mockClear();
  441. const handler = new EmailEventListener('bad-template')
  442. .on(MockEvent)
  443. .setFrom('"test from" <noreply@test.com>')
  444. .setRecipient(() => 'test@test.com')
  445. .setSubject('Hello {{ subjectVar }}');
  446. await initPluginWithHandlers([handler]);
  447. eventBus.publish(new MockEvent(ctx, true));
  448. await pause();
  449. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain(`Parse error on line 3:`);
  450. });
  451. it('Logs an error if the loadData method throws', async () => {
  452. const ctx = RequestContext.deserialize({
  453. _channel: { code: DEFAULT_CHANNEL_CODE },
  454. _languageCode: LanguageCode.en,
  455. } as any);
  456. testingLogger.errorSpy.mockClear();
  457. const handler = new EmailEventListener('bad-template')
  458. .on(MockEvent)
  459. .setFrom('"test from" <noreply@test.com>')
  460. .setRecipient(() => 'test@test.com')
  461. .setSubject('Hello {{ subjectVar }}')
  462. .loadData(context => {
  463. throw new Error('something went horribly wrong!');
  464. });
  465. await initPluginWithHandlers([handler]);
  466. eventBus.publish(new MockEvent(ctx, true));
  467. await pause();
  468. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain(`something went horribly wrong!`);
  469. });
  470. });
  471. });
  472. const pause = () => new Promise(resolve => setTimeout(resolve, 100));
  473. class MockEvent extends VendureEvent {
  474. constructor(public ctx: RequestContext, public shouldSend: boolean) {
  475. super();
  476. }
  477. }
  478. class MockService {
  479. someAsyncMethod() {
  480. return Promise.resolve('loaded data');
  481. }
  482. }