plugin.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. /* eslint-disable @typescript-eslint/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. DefaultLogger,
  7. EventBus,
  8. Injector,
  9. LanguageCode,
  10. Logger,
  11. LogLevel,
  12. Order,
  13. OrderStateTransitionEvent,
  14. PluginCommonModule,
  15. RequestContext,
  16. VendureEvent,
  17. } from '@vendure/core';
  18. import { ensureConfigLoaded } from '@vendure/core/dist/config/config-helpers';
  19. import { TestingLogger } from '@vendure/testing';
  20. import { createReadStream, readFileSync } from 'fs';
  21. import path from 'path';
  22. import { Readable } from 'stream';
  23. import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
  24. import { orderConfirmationHandler } from './handler/default-email-handlers';
  25. import { EmailProcessor } from './email-processor';
  26. import { EmailSender } from './sender/email-sender';
  27. import { EmailEventHandler } from './handler/event-handler';
  28. import { EmailEventListener } from './event-listener';
  29. import { EmailPlugin } from './plugin';
  30. import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
  31. describe('EmailPlugin', () => {
  32. let eventBus: EventBus;
  33. let onSend: Mock;
  34. let module: TestingModule;
  35. const testingLogger = new TestingLogger((...args) => vi.fn(...args));
  36. async function initPluginWithHandlers(
  37. handlers: Array<EmailEventHandler<string, any>>,
  38. options?: Partial<EmailPluginOptions>,
  39. ) {
  40. await ensureConfigLoaded();
  41. onSend = vi.fn();
  42. module = await Test.createTestingModule({
  43. imports: [
  44. TypeOrmModule.forRoot({
  45. type: 'sqljs',
  46. retryAttempts: 0,
  47. }),
  48. PluginCommonModule,
  49. EmailPlugin.init({
  50. templatePath: path.join(__dirname, '../test-templates'),
  51. transport: {
  52. type: 'testing',
  53. onSend,
  54. },
  55. handlers,
  56. ...options,
  57. }),
  58. ],
  59. providers: [MockService],
  60. }).compile();
  61. Logger.useLogger(testingLogger);
  62. module.useLogger(new Logger());
  63. const plugin = module.get(EmailPlugin);
  64. eventBus = module.get(EventBus);
  65. await plugin.onApplicationBootstrap();
  66. return module;
  67. }
  68. afterEach(async () => {
  69. if (module) {
  70. await module.close();
  71. }
  72. });
  73. it('setting from, recipient, subject', async () => {
  74. const ctx = RequestContext.deserialize({
  75. _channel: { code: DEFAULT_CHANNEL_CODE },
  76. _languageCode: LanguageCode.en,
  77. } as any);
  78. const handler = new EmailEventListener('test')
  79. .on(MockEvent)
  80. .setFrom('"test from" <noreply@test.com>')
  81. .setRecipient(() => 'test@test.com')
  82. .setSubject('Hello')
  83. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  84. await initPluginWithHandlers([handler]);
  85. eventBus.publish(new MockEvent(ctx, true));
  86. await pause();
  87. expect(onSend.mock.calls[0][0].subject).toBe('Hello');
  88. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  89. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  90. });
  91. describe('event filtering', () => {
  92. const ctx = RequestContext.deserialize({
  93. _channel: { code: DEFAULT_CHANNEL_CODE },
  94. _languageCode: LanguageCode.en,
  95. } as any);
  96. it('single filter', async () => {
  97. const handler = new EmailEventListener('test')
  98. .on(MockEvent)
  99. .filter(event => event.shouldSend === true)
  100. .setRecipient(() => 'test@test.com')
  101. .setFrom('"test from" <noreply@test.com>')
  102. .setSubject('test subject');
  103. await initPluginWithHandlers([handler]);
  104. eventBus.publish(new MockEvent(ctx, false));
  105. await pause();
  106. expect(onSend).not.toHaveBeenCalled();
  107. eventBus.publish(new MockEvent(ctx, true));
  108. await pause();
  109. expect(onSend).toHaveBeenCalledTimes(1);
  110. });
  111. it('multiple filters', async () => {
  112. const handler = new EmailEventListener('test')
  113. .on(MockEvent)
  114. .filter(event => event.shouldSend === true)
  115. .filter(event => !!event.ctx.activeUserId)
  116. .setFrom('"test from" <noreply@test.com>')
  117. .setRecipient(() => 'test@test.com')
  118. .setSubject('test subject');
  119. await initPluginWithHandlers([handler]);
  120. eventBus.publish(new MockEvent(ctx, true));
  121. await pause();
  122. expect(onSend).not.toHaveBeenCalled();
  123. const ctxWithUser = RequestContext.deserialize({ ...ctx, _session: { user: { id: 42 } } } as any);
  124. eventBus.publish(new MockEvent(ctxWithUser, true));
  125. await pause();
  126. expect(onSend).toHaveBeenCalledTimes(1);
  127. });
  128. it('with .loadData() after .filter()', async () => {
  129. const handler = new EmailEventListener('test')
  130. .on(MockEvent)
  131. .filter(event => event.shouldSend === true)
  132. .loadData(context => Promise.resolve('loaded data'))
  133. .setRecipient(() => 'test@test.com')
  134. .setFrom('"test from" <noreply@test.com>')
  135. .setSubject('test subject');
  136. await initPluginWithHandlers([handler]);
  137. eventBus.publish(new MockEvent(ctx, false));
  138. await pause();
  139. expect(onSend).not.toHaveBeenCalled();
  140. eventBus.publish(new MockEvent(ctx, true));
  141. await pause();
  142. expect(onSend).toHaveBeenCalledTimes(1);
  143. });
  144. });
  145. describe('templateVars', () => {
  146. const ctx = RequestContext.deserialize({
  147. _channel: { code: DEFAULT_CHANNEL_CODE },
  148. _languageCode: LanguageCode.en,
  149. } as any);
  150. it('interpolates subject', async () => {
  151. const handler = new EmailEventListener('test')
  152. .on(MockEvent)
  153. .setFrom('"test from" <noreply@test.com>')
  154. .setRecipient(() => 'test@test.com')
  155. .setSubject('Hello {{ subjectVar }}')
  156. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  157. await initPluginWithHandlers([handler]);
  158. eventBus.publish(new MockEvent(ctx, true));
  159. await pause();
  160. expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
  161. });
  162. it('interpolates body', async () => {
  163. const handler = new EmailEventListener('test')
  164. .on(MockEvent)
  165. .setFrom('"test from" <noreply@test.com>')
  166. .setRecipient(() => 'test@test.com')
  167. .setSubject('Hello')
  168. .setTemplateVars(event => ({ testVar: 'this is the test var' }));
  169. await initPluginWithHandlers([handler]);
  170. eventBus.publish(new MockEvent(ctx, true));
  171. await pause();
  172. expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
  173. });
  174. /**
  175. * Intended to test the ability for Handlebars to interpolate
  176. * getters on the Order entity prototype.
  177. * See https://github.com/vendure-ecommerce/vendure/issues/259
  178. */
  179. it('interpolates body with property from entity', async () => {
  180. const handler = new EmailEventListener('test')
  181. .on(MockEvent)
  182. .setFrom('"test from" <noreply@test.com>')
  183. .setRecipient(() => 'test@test.com')
  184. .setSubject('Hello')
  185. .setTemplateVars(event => ({ order: new Order({ subTotal: 123 }) }));
  186. await initPluginWithHandlers([handler]);
  187. eventBus.publish(new MockEvent(ctx, true));
  188. await pause();
  189. expect(onSend.mock.calls[0][0].body).toContain('Total: 123');
  190. });
  191. it('interpolates globalTemplateVars', async () => {
  192. const handler = new EmailEventListener('test')
  193. .on(MockEvent)
  194. .setFrom('"test from" <noreply@test.com>')
  195. .setRecipient(() => 'test@test.com')
  196. .setSubject('Hello {{ globalVar }}');
  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].subject).toBe('Hello baz');
  203. });
  204. it('interpolates from', async () => {
  205. const handler = new EmailEventListener('test')
  206. .on(MockEvent)
  207. .setFrom('"test from {{ globalVar }}" <noreply@test.com>')
  208. .setRecipient(() => 'test@test.com')
  209. .setSubject('Hello');
  210. await initPluginWithHandlers([handler], {
  211. globalTemplateVars: { globalVar: 'baz' },
  212. });
  213. eventBus.publish(new MockEvent(ctx, true));
  214. await pause();
  215. expect(onSend.mock.calls[0][0].from).toBe('"test from baz" <noreply@test.com>');
  216. });
  217. // Test fix for https://github.com/vendure-ecommerce/vendure/issues/363
  218. it('does not escape HTML chars when interpolating "from"', async () => {
  219. const handler = new EmailEventListener('test')
  220. .on(MockEvent)
  221. .setFrom('{{ globalFrom }}')
  222. .setRecipient(() => 'Test <test@test.com>')
  223. .setSubject('Hello');
  224. await initPluginWithHandlers([handler], {
  225. globalTemplateVars: { globalFrom: 'Test <test@test.com>' },
  226. });
  227. eventBus.publish(new MockEvent(ctx, true));
  228. await pause();
  229. expect(onSend.mock.calls[0][0].from).toBe('Test <test@test.com>');
  230. });
  231. it('globalTemplateVars available in setTemplateVars method', async () => {
  232. const handler = new EmailEventListener('test')
  233. .on(MockEvent)
  234. .setFrom('"test from" <noreply@test.com>')
  235. .setRecipient(() => 'test@test.com')
  236. .setSubject('Hello {{ testVar }}')
  237. .setTemplateVars((event, globals) => ({ testVar: (globals.globalVar as string) + ' quux' }));
  238. await initPluginWithHandlers([handler], {
  239. globalTemplateVars: { globalVar: 'baz' },
  240. });
  241. eventBus.publish(new MockEvent(ctx, true));
  242. await pause();
  243. expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
  244. });
  245. it('setTemplateVars overrides globals', async () => {
  246. const handler = new EmailEventListener('test')
  247. .on(MockEvent)
  248. .setFrom('"test from" <noreply@test.com>')
  249. .setRecipient(() => 'test@test.com')
  250. .setSubject('Hello {{ name }}')
  251. .setTemplateVars((event, globals) => ({ name: 'quux' }));
  252. await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
  253. eventBus.publish(new MockEvent(ctx, true));
  254. await pause();
  255. expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
  256. });
  257. });
  258. describe('handlebars helpers', () => {
  259. const ctx = RequestContext.deserialize({
  260. _channel: { code: DEFAULT_CHANNEL_CODE },
  261. _languageCode: LanguageCode.en,
  262. } as any);
  263. it('formateDate', async () => {
  264. const handler = new EmailEventListener('test-helpers')
  265. .on(MockEvent)
  266. .setFrom('"test from" <noreply@test.com>')
  267. .setRecipient(() => 'test@test.com')
  268. .setSubject('Hello')
  269. .setTemplateVars(event => ({ myDate: new Date('2020-01-01T10:00:00.000Z'), myPrice: 0 }));
  270. await initPluginWithHandlers([handler]);
  271. eventBus.publish(new MockEvent(ctx, true));
  272. await pause();
  273. expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
  274. });
  275. it('formatMoney', async () => {
  276. const handler = new EmailEventListener('test-helpers')
  277. .on(MockEvent)
  278. .setFrom('"test from" <noreply@test.com>')
  279. .setRecipient(() => 'test@test.com')
  280. .setSubject('Hello')
  281. .setTemplateVars(event => ({ myDate: new Date(), myPrice: 123 }));
  282. await initPluginWithHandlers([handler]);
  283. eventBus.publish(new MockEvent(ctx, true));
  284. await pause();
  285. expect(onSend.mock.calls[0][0].body).toContain('Price: 1.23');
  286. expect(onSend.mock.calls[0][0].body).toContain('Price: €1.23');
  287. expect(onSend.mock.calls[0][0].body).toContain('Price: £1.23');
  288. });
  289. });
  290. describe('multiple configs', () => {
  291. const ctx = RequestContext.deserialize({
  292. _channel: { code: DEFAULT_CHANNEL_CODE },
  293. _languageCode: LanguageCode.en,
  294. } as any);
  295. it('additional LanguageCode', async () => {
  296. const handler = new EmailEventListener('test')
  297. .on(MockEvent)
  298. .setFrom('"test from" <noreply@test.com>')
  299. .setSubject('Hello, {{ name }}!')
  300. .setRecipient(() => 'test@test.com')
  301. .setTemplateVars(() => ({ name: 'Test' }))
  302. .addTemplate({
  303. channelCode: 'default',
  304. languageCode: LanguageCode.de,
  305. templateFile: 'body.de.hbs',
  306. subject: 'Servus, {{ name }}!',
  307. });
  308. await initPluginWithHandlers([handler]);
  309. const ctxTa = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.ta } as any);
  310. eventBus.publish(new MockEvent(ctxTa, true));
  311. await pause();
  312. expect(onSend.mock.calls[0][0].subject).toBe('Hello, Test!');
  313. expect(onSend.mock.calls[0][0].body).toContain('Default body.');
  314. const ctxDe = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.de } as any);
  315. eventBus.publish(new MockEvent(ctxDe, true));
  316. await pause();
  317. expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
  318. expect(onSend.mock.calls[1][0].body).toContain('German body.');
  319. });
  320. it('set LanguageCode', async () => {
  321. const handler = new EmailEventListener('test')
  322. .on(MockEvent)
  323. .setFrom('"test from" <noreply@test.com>')
  324. .setSubject('Hello, {{ name }}!')
  325. .setRecipient(() => 'test@test.com')
  326. .setLanguageCode(() => LanguageCode.de)
  327. .setTemplateVars(() => ({ name: 'Test' }))
  328. .addTemplate({
  329. channelCode: 'default',
  330. languageCode: LanguageCode.de,
  331. templateFile: 'body.de.hbs',
  332. subject: 'Servus, {{ name }}!',
  333. });
  334. const ctxEn = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.en } as any);
  335. eventBus.publish(new MockEvent(ctxEn, true));
  336. await pause();
  337. expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
  338. expect(onSend.mock.calls[1][0].body).toContain('German body.');
  339. });
  340. });
  341. describe('loadData', () => {
  342. it('loads async data', async () => {
  343. const handler = new EmailEventListener('test')
  344. .on(MockEvent)
  345. .loadData(async ({ injector }) => {
  346. const service = injector.get(MockService);
  347. return service.someAsyncMethod();
  348. })
  349. .setFrom('"test from" <noreply@test.com>')
  350. .setSubject('Hello, {{ testData }}!')
  351. .setRecipient(() => 'test@test.com')
  352. .setTemplateVars(event => ({ testData: event.data }));
  353. await initPluginWithHandlers([handler]);
  354. eventBus.publish(
  355. new MockEvent(
  356. RequestContext.deserialize({
  357. _channel: { code: DEFAULT_CHANNEL_CODE },
  358. _languageCode: LanguageCode.en,
  359. } as any),
  360. true,
  361. ),
  362. );
  363. await pause();
  364. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  365. });
  366. it('works when loadData is called after other setup', async () => {
  367. const handler = new EmailEventListener('test')
  368. .on(MockEvent)
  369. .setFrom('"test from" <noreply@test.com>')
  370. .setSubject('Hello, {{ testData }}!')
  371. .setRecipient(() => 'test@test.com')
  372. .loadData(async ({ injector }) => {
  373. const service = injector.get(MockService);
  374. return service.someAsyncMethod();
  375. })
  376. .setTemplateVars(event => ({ testData: event.data }));
  377. await initPluginWithHandlers([handler]);
  378. eventBus.publish(
  379. new MockEvent(
  380. RequestContext.deserialize({
  381. _channel: { code: DEFAULT_CHANNEL_CODE },
  382. _languageCode: LanguageCode.en,
  383. } as any),
  384. true,
  385. ),
  386. );
  387. await pause();
  388. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  389. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  390. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  391. });
  392. it('only executes for filtered events', async () => {
  393. let callCount = 0;
  394. const handler = new EmailEventListener('test')
  395. .on(MockEvent)
  396. .filter(event => event.shouldSend === true)
  397. .loadData(async ({ injector }) => {
  398. callCount++;
  399. });
  400. await initPluginWithHandlers([handler]);
  401. eventBus.publish(new MockEvent(RequestContext.empty(), false));
  402. eventBus.publish(new MockEvent(RequestContext.empty(), true));
  403. await pause();
  404. expect(callCount).toBe(1);
  405. });
  406. });
  407. describe('attachments', () => {
  408. const ctx = RequestContext.deserialize({
  409. _channel: { code: DEFAULT_CHANNEL_CODE },
  410. _languageCode: LanguageCode.en,
  411. } as any);
  412. const TEST_IMAGE_PATH = path.join(__dirname, '../test-fixtures/test.jpg');
  413. it('attachments are empty by default', async () => {
  414. const handler = new EmailEventListener('test')
  415. .on(MockEvent)
  416. .setFrom('"test from" <noreply@test.com>')
  417. .setRecipient(() => 'test@test.com')
  418. .setSubject('Hello {{ subjectVar }}');
  419. await initPluginWithHandlers([handler]);
  420. eventBus.publish(new MockEvent(ctx, true));
  421. await pause();
  422. expect(onSend.mock.calls[0][0].attachments).toEqual([]);
  423. });
  424. it('sync attachment', async () => {
  425. const handler = new EmailEventListener('test')
  426. .on(MockEvent)
  427. .setFrom('"test from" <noreply@test.com>')
  428. .setRecipient(() => 'test@test.com')
  429. .setSubject('Hello {{ subjectVar }}')
  430. .setAttachments(() => [
  431. {
  432. path: TEST_IMAGE_PATH,
  433. },
  434. ]);
  435. await initPluginWithHandlers([handler]);
  436. eventBus.publish(new MockEvent(ctx, true));
  437. await pause();
  438. expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
  439. });
  440. it('async attachment', async () => {
  441. const handler = new EmailEventListener('test')
  442. .on(MockEvent)
  443. .setFrom('"test from" <noreply@test.com>')
  444. .setRecipient(() => 'test@test.com')
  445. .setSubject('Hello {{ subjectVar }}')
  446. .setAttachments(async () => [
  447. {
  448. path: TEST_IMAGE_PATH,
  449. },
  450. ]);
  451. await initPluginWithHandlers([handler]);
  452. eventBus.publish(new MockEvent(ctx, true));
  453. await pause();
  454. expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
  455. });
  456. it('attachment content as a string buffer', async () => {
  457. const handler = new EmailEventListener('test')
  458. .on(MockEvent)
  459. .setFrom('"test from" <noreply@test.com>')
  460. .setRecipient(() => 'test@test.com')
  461. .setSubject('Hello {{ subjectVar }}')
  462. .setAttachments(() => [
  463. {
  464. content: Buffer.from('hello'),
  465. },
  466. ]);
  467. await initPluginWithHandlers([handler]);
  468. eventBus.publish(new MockEvent(ctx, true));
  469. await pause();
  470. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  471. expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
  472. });
  473. it('attachment content as an image buffer', async () => {
  474. const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
  475. const handler = new EmailEventListener('test')
  476. .on(MockEvent)
  477. .setFrom('"test from" <noreply@test.com>')
  478. .setRecipient(() => 'test@test.com')
  479. .setSubject('Hello {{ subjectVar }}')
  480. .setAttachments(() => [
  481. {
  482. content: imageFileBuffer,
  483. },
  484. ]);
  485. await initPluginWithHandlers([handler]);
  486. eventBus.publish(new MockEvent(ctx, true));
  487. await pause();
  488. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  489. expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
  490. });
  491. it('attachment content as a string', async () => {
  492. const handler = new EmailEventListener('test')
  493. .on(MockEvent)
  494. .setFrom('"test from" <noreply@test.com>')
  495. .setRecipient(() => 'test@test.com')
  496. .setSubject('Hello {{ subjectVar }}')
  497. .setAttachments(() => [
  498. {
  499. content: 'hello',
  500. },
  501. ]);
  502. await initPluginWithHandlers([handler]);
  503. eventBus.publish(new MockEvent(ctx, true));
  504. await pause();
  505. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  506. expect(attachment).toBe('hello');
  507. });
  508. it('attachment content as a string stream', async () => {
  509. const handler = new EmailEventListener('test')
  510. .on(MockEvent)
  511. .setFrom('"test from" <noreply@test.com>')
  512. .setRecipient(() => 'test@test.com')
  513. .setSubject('Hello {{ subjectVar }}')
  514. .setAttachments(() => [
  515. {
  516. content: Readable.from(['hello']),
  517. },
  518. ]);
  519. await initPluginWithHandlers([handler]);
  520. eventBus.publish(new MockEvent(ctx, true));
  521. await pause();
  522. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  523. expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
  524. });
  525. it('attachment content as an image stream', async () => {
  526. const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
  527. const imageFileStream = createReadStream(TEST_IMAGE_PATH);
  528. const handler = new EmailEventListener('test')
  529. .on(MockEvent)
  530. .setFrom('"test from" <noreply@test.com>')
  531. .setRecipient(() => 'test@test.com')
  532. .setSubject('Hello {{ subjectVar }}')
  533. .setAttachments(() => [
  534. {
  535. content: imageFileStream,
  536. },
  537. ]);
  538. await initPluginWithHandlers([handler]);
  539. eventBus.publish(new MockEvent(ctx, true));
  540. await pause();
  541. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  542. expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
  543. });
  544. it('raises a warning for large content attachments', async () => {
  545. testingLogger.warnSpy.mockClear();
  546. const largeBuffer = Buffer.from(Array.from({ length: 65535, 0: 0 }));
  547. const handler = new EmailEventListener('test')
  548. .on(MockEvent)
  549. .setFrom('"test from" <noreply@test.com>')
  550. .setRecipient(() => 'test@test.com')
  551. .setSubject('Hello {{ subjectVar }}')
  552. .setAttachments(() => [
  553. {
  554. content: largeBuffer,
  555. },
  556. ]);
  557. await initPluginWithHandlers([handler]);
  558. eventBus.publish(new MockEvent(ctx, true));
  559. await pause();
  560. expect(testingLogger.warnSpy.mock.calls[0][0]).toContain(
  561. "Email has a large 'content' attachment (64k). Consider using the 'path' instead for improved performance.",
  562. );
  563. });
  564. });
  565. describe('orderConfirmationHandler', () => {
  566. beforeEach(async () => {
  567. await initPluginWithHandlers([orderConfirmationHandler], {
  568. templatePath: path.join(__dirname, '../templates'),
  569. });
  570. });
  571. const ctx = RequestContext.deserialize({
  572. _channel: { code: DEFAULT_CHANNEL_CODE },
  573. _languageCode: LanguageCode.en,
  574. } as any);
  575. const order = {
  576. code: 'ABCDE',
  577. customer: {
  578. emailAddress: 'test@test.com',
  579. },
  580. lines: [],
  581. } as any;
  582. it('filters events with wrong order state', async () => {
  583. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order));
  584. await pause();
  585. expect(onSend).not.toHaveBeenCalled();
  586. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'Cancelled', ctx, order));
  587. await pause();
  588. expect(onSend).not.toHaveBeenCalled();
  589. eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'PaymentAuthorized', ctx, order));
  590. await pause();
  591. expect(onSend).not.toHaveBeenCalled();
  592. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  593. await pause();
  594. expect(onSend).toHaveBeenCalledTimes(1);
  595. });
  596. it('sets the Order Customer emailAddress as recipient', async () => {
  597. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  598. await pause();
  599. expect(onSend.mock.calls[0][0].recipient).toBe(order.customer!.emailAddress);
  600. });
  601. it('sets the subject', async () => {
  602. eventBus.publish(new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order));
  603. await pause();
  604. expect(onSend.mock.calls[0][0].subject).toBe(`Order confirmation for #${order.code as string}`);
  605. });
  606. });
  607. describe('error handling', () => {
  608. it('Logs an error if the template file is not found', async () => {
  609. const ctx = RequestContext.deserialize({
  610. _channel: { code: DEFAULT_CHANNEL_CODE },
  611. _languageCode: LanguageCode.en,
  612. } as any);
  613. testingLogger.errorSpy.mockClear();
  614. const handler = new EmailEventListener('no-template')
  615. .on(MockEvent)
  616. .setFrom('"test from" <noreply@test.com>')
  617. .setRecipient(() => 'test@test.com')
  618. .setSubject('Hello {{ subjectVar }}');
  619. await initPluginWithHandlers([handler]);
  620. eventBus.publish(new MockEvent(ctx, true));
  621. await pause();
  622. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('ENOENT: no such file or directory');
  623. });
  624. it('Logs a Handlebars error if the template is invalid', async () => {
  625. const ctx = RequestContext.deserialize({
  626. _channel: { code: DEFAULT_CHANNEL_CODE },
  627. _languageCode: LanguageCode.en,
  628. } as any);
  629. testingLogger.errorSpy.mockClear();
  630. const handler = new EmailEventListener('bad-template')
  631. .on(MockEvent)
  632. .setFrom('"test from" <noreply@test.com>')
  633. .setRecipient(() => 'test@test.com')
  634. .setSubject('Hello {{ subjectVar }}');
  635. await initPluginWithHandlers([handler]);
  636. eventBus.publish(new MockEvent(ctx, true));
  637. await pause();
  638. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('Parse error on line 3:');
  639. });
  640. it('Logs an error if the loadData method throws', async () => {
  641. const ctx = RequestContext.deserialize({
  642. _channel: { code: DEFAULT_CHANNEL_CODE },
  643. _languageCode: LanguageCode.en,
  644. } as any);
  645. testingLogger.errorSpy.mockClear();
  646. const handler = new EmailEventListener('bad-template')
  647. .on(MockEvent)
  648. .setFrom('"test from" <noreply@test.com>')
  649. .setRecipient(() => 'test@test.com')
  650. .setSubject('Hello {{ subjectVar }}')
  651. .loadData(context => {
  652. throw new Error('something went horribly wrong!');
  653. });
  654. await initPluginWithHandlers([handler]);
  655. eventBus.publish(new MockEvent(ctx, true));
  656. await pause();
  657. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('something went horribly wrong!');
  658. });
  659. });
  660. describe('custom sender', () => {
  661. it('should allow a custom sender to be utilized', async () => {
  662. const ctx = RequestContext.deserialize({
  663. _channel: { code: DEFAULT_CHANNEL_CODE },
  664. _languageCode: LanguageCode.en,
  665. } as any);
  666. const handler = new EmailEventListener('test')
  667. .on(MockEvent)
  668. .setFrom('"test from" <noreply@test.com>')
  669. .setRecipient(() => 'test@test.com')
  670. .setSubject('Hello')
  671. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  672. const fakeSender = new FakeCustomSender();
  673. const send = vi.fn();
  674. fakeSender.send = send;
  675. await initPluginWithHandlers([handler], {
  676. emailSender: fakeSender,
  677. });
  678. eventBus.publish(new MockEvent(ctx, true));
  679. await pause();
  680. expect(send.mock.calls[0][0].subject).toBe('Hello');
  681. expect(send.mock.calls[0][0].recipient).toBe('test@test.com');
  682. expect(send.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  683. expect(onSend).toHaveBeenCalledTimes(0);
  684. });
  685. });
  686. describe('optional address fields', () => {
  687. const ctx = RequestContext.deserialize({
  688. _channel: { code: DEFAULT_CHANNEL_CODE },
  689. _languageCode: LanguageCode.en,
  690. } as any);
  691. it('cc', async () => {
  692. const handler = new EmailEventListener('test')
  693. .on(MockEvent)
  694. .setFrom('"test from" <noreply@test.com>')
  695. .setRecipient(() => 'test@test.com')
  696. .setSubject('Hello {{ subjectVar }}')
  697. .setOptionalAddressFields(() => ({ cc: 'foo@bar.com' }));
  698. await initPluginWithHandlers([handler]);
  699. eventBus.publish(new MockEvent(ctx, true));
  700. await pause();
  701. expect(onSend.mock.calls[0][0].cc).toBe('foo@bar.com');
  702. });
  703. it('bcc', async () => {
  704. const handler = new EmailEventListener('test')
  705. .on(MockEvent)
  706. .setFrom('"test from" <noreply@test.com>')
  707. .setRecipient(() => 'test@test.com')
  708. .setSubject('Hello {{ subjectVar }}')
  709. .setOptionalAddressFields(() => ({ bcc: 'foo@bar.com' }));
  710. await initPluginWithHandlers([handler]);
  711. eventBus.publish(new MockEvent(ctx, true));
  712. await pause();
  713. expect(onSend.mock.calls[0][0].bcc).toBe('foo@bar.com');
  714. });
  715. it('replyTo', async () => {
  716. const handler = new EmailEventListener('test')
  717. .on(MockEvent)
  718. .setFrom('"test from" <noreply@test.com>')
  719. .setRecipient(() => 'test@test.com')
  720. .setSubject('Hello {{ subjectVar }}')
  721. .setOptionalAddressFields(() => ({ replyTo: 'foo@bar.com' }));
  722. await initPluginWithHandlers([handler]);
  723. eventBus.publish(new MockEvent(ctx, true));
  724. await pause();
  725. expect(onSend.mock.calls[0][0].replyTo).toBe('foo@bar.com');
  726. });
  727. });
  728. describe('Dynamic transport settings', () => {
  729. let injectorArg: Injector | undefined;
  730. let ctxArg: RequestContext | undefined;
  731. it('Initializes with async transport settings', async () => {
  732. const handler = new EmailEventListener('test')
  733. .on(MockEvent)
  734. .setFrom('"test from" <noreply@test.com>')
  735. .setRecipient(() => 'test@test.com')
  736. .setSubject('Hello')
  737. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  738. module = await initPluginWithHandlers([handler], {
  739. transport: async (injector, ctx) => {
  740. injectorArg = injector;
  741. ctxArg = ctx;
  742. return {
  743. type: 'testing',
  744. onSend: () => {},
  745. };
  746. },
  747. });
  748. const ctx = RequestContext.deserialize({
  749. _channel: { code: DEFAULT_CHANNEL_CODE },
  750. _languageCode: LanguageCode.en,
  751. } as any);
  752. module!.get(EventBus).publish(new MockEvent(ctx, true));
  753. await pause();
  754. expect(module).toBeDefined();
  755. expect(typeof module.get(EmailPlugin).options.transport).toBe('function');
  756. });
  757. it('Passes injector and context to transport function', async () => {
  758. const ctx = RequestContext.deserialize({
  759. _channel: { code: DEFAULT_CHANNEL_CODE },
  760. _languageCode: LanguageCode.en,
  761. } as any);
  762. module!.get(EventBus).publish(new MockEvent(ctx, true));
  763. await pause();
  764. expect(injectorArg?.constructor.name).toBe('Injector');
  765. expect(ctxArg?.constructor.name).toBe('RequestContext');
  766. });
  767. it('Resolves async transport settings', async () => {
  768. const transport = await module!.get(EmailProcessor).getTransportSettings();
  769. expect(transport.type).toBe('testing');
  770. });
  771. });
  772. });
  773. class FakeCustomSender implements EmailSender {
  774. send: (email: EmailDetails<'unserialized'>, options: EmailTransportOptions) => void;
  775. }
  776. const pause = () => new Promise(resolve => setTimeout(resolve, 100));
  777. class MockEvent extends VendureEvent {
  778. constructor(public ctx: RequestContext, public shouldSend: boolean) {
  779. super();
  780. }
  781. }
  782. class MockService {
  783. someAsyncMethod() {
  784. return Promise.resolve('loaded data');
  785. }
  786. }