plugin.spec.ts 18 KB

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