product-prices.e2e-spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { CurrencyCode, LanguageCode } from '@vendure/common/lib/generated-types';
  3. import { pick } from '@vendure/common/lib/pick';
  4. import { mergeConfig } from '@vendure/core';
  5. import {
  6. createErrorResultGuard,
  7. createTestEnvironment,
  8. E2E_DEFAULT_CHANNEL_TOKEN,
  9. ErrorResultGuard,
  10. } from '@vendure/testing';
  11. import path from 'path';
  12. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
  13. import { initialData } from '../../../e2e-common/e2e-initial-data';
  14. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  15. import { ProductVariantPrice, ProductVariantPriceUpdateStrategy, RequestContext } from '../src/index';
  16. import { FragmentOf, ResultOf } from './graphql/graphql-admin';
  17. import {
  18. assignProductToChannelDocument,
  19. createChannelDocument,
  20. createProductDocument,
  21. createProductVariantsDocument,
  22. getProductWithVariantsDocument,
  23. updateChannelDocument,
  24. updateProductVariantsDocument,
  25. } from './graphql/shared-definitions';
  26. import {
  27. addItemToOrderDocument,
  28. adjustItemQuantityDocument,
  29. getActiveOrderDocument,
  30. testOrderFragment,
  31. updatedOrderFragment,
  32. } from './graphql/shop-definitions';
  33. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  34. class TestProductVariantPriceUpdateStrategy implements ProductVariantPriceUpdateStrategy {
  35. static syncAcrossChannels = false;
  36. static onCreatedSpy = vi.fn();
  37. static onUpdatedSpy = vi.fn();
  38. static onDeletedSpy = vi.fn();
  39. onPriceCreated(ctx: RequestContext, price: ProductVariantPrice, prices: ProductVariantPrice[]) {
  40. TestProductVariantPriceUpdateStrategy.onCreatedSpy(price, prices);
  41. return [];
  42. }
  43. onPriceUpdated(ctx: RequestContext, updatedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
  44. TestProductVariantPriceUpdateStrategy.onUpdatedSpy(updatedPrice, prices);
  45. if (TestProductVariantPriceUpdateStrategy.syncAcrossChannels) {
  46. return prices
  47. .filter(p => p.currencyCode === updatedPrice.currencyCode)
  48. .map(p => ({
  49. id: p.id,
  50. price: updatedPrice.price,
  51. }));
  52. } else {
  53. return [];
  54. }
  55. }
  56. onPriceDeleted(ctx: RequestContext, deletedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
  57. TestProductVariantPriceUpdateStrategy.onDeletedSpy(deletedPrice, prices);
  58. return [];
  59. }
  60. }
  61. describe('Product prices', () => {
  62. const { server, adminClient, shopClient } = createTestEnvironment(
  63. mergeConfig(
  64. { ...testConfig() },
  65. {
  66. catalogOptions: {
  67. productVariantPriceUpdateStrategy: new TestProductVariantPriceUpdateStrategy(),
  68. },
  69. },
  70. ),
  71. );
  72. let multiPriceProduct: ResultOf<typeof createProductDocument>['createProduct'];
  73. let multiPriceVariant: NonNullable<
  74. ResultOf<typeof createProductVariantsDocument>['createProductVariants'][number]
  75. >;
  76. const orderResultGuard: ErrorResultGuard<
  77. FragmentOf<typeof testOrderFragment> | FragmentOf<typeof updatedOrderFragment>
  78. > = createErrorResultGuard(input => !!input.lines);
  79. const createChannelResultGuard: ErrorResultGuard<{ id: string }> = createErrorResultGuard(
  80. input => !!input.id,
  81. );
  82. beforeAll(async () => {
  83. await server.init({
  84. initialData,
  85. customerCount: 1,
  86. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  87. });
  88. await adminClient.asSuperAdmin();
  89. await adminClient.query(updateChannelDocument, {
  90. input: {
  91. id: 'T_1',
  92. availableCurrencyCodes: [CurrencyCode.USD, CurrencyCode.GBP, CurrencyCode.EUR],
  93. },
  94. });
  95. const { createProduct } = await adminClient.query(createProductDocument, {
  96. input: {
  97. translations: [
  98. {
  99. languageCode: LanguageCode.en,
  100. name: 'Cactus',
  101. slug: 'cactus',
  102. description: 'A prickly plant',
  103. },
  104. ],
  105. },
  106. });
  107. multiPriceProduct = createProduct;
  108. }, TEST_SETUP_TIMEOUT_MS);
  109. afterAll(async () => {
  110. await server.destroy();
  111. });
  112. it('create ProductVariant creates price in Channel default currency', async () => {
  113. const { createProductVariants } = await adminClient.query(createProductVariantsDocument, {
  114. input: [
  115. {
  116. productId: multiPriceProduct.id,
  117. sku: 'CACTUS-1',
  118. optionIds: [],
  119. translations: [{ languageCode: LanguageCode.de, name: 'Cactus' }],
  120. price: 1000,
  121. },
  122. ],
  123. });
  124. expect(createProductVariants.length).toBe(1);
  125. expect(createProductVariants[0]?.prices).toEqual([
  126. {
  127. currencyCode: CurrencyCode.USD,
  128. price: 1000,
  129. },
  130. ]);
  131. multiPriceVariant = createProductVariants[0]!;
  132. });
  133. it(
  134. 'updating ProductVariant with price in unavailable currency throws',
  135. assertThrowsWithMessage(async () => {
  136. await adminClient.query(updateProductVariantsDocument, {
  137. input: {
  138. id: multiPriceVariant.id,
  139. prices: [
  140. {
  141. currencyCode: CurrencyCode.JPY,
  142. price: 100000,
  143. },
  144. ],
  145. },
  146. });
  147. }, 'The currency "JPY" is not available in the current Channel'),
  148. );
  149. it('updates ProductVariant with multiple prices', async () => {
  150. await adminClient.query(updateProductVariantsDocument, {
  151. input: {
  152. id: multiPriceVariant.id,
  153. prices: [
  154. { currencyCode: CurrencyCode.USD, price: 1200 },
  155. { currencyCode: CurrencyCode.GBP, price: 900 },
  156. { currencyCode: CurrencyCode.EUR, price: 1100 },
  157. ],
  158. },
  159. });
  160. const { product } = await adminClient.query(getProductWithVariantsDocument, {
  161. id: multiPriceProduct.id,
  162. });
  163. expect(product?.variants[0]?.prices.sort((a, b) => a.price - b.price)).toEqual([
  164. { currencyCode: CurrencyCode.GBP, price: 900 },
  165. { currencyCode: CurrencyCode.EUR, price: 1100 },
  166. { currencyCode: CurrencyCode.USD, price: 1200 },
  167. ]);
  168. });
  169. it('deletes a price in a non-default currency', async () => {
  170. await adminClient.query(updateProductVariantsDocument, {
  171. input: {
  172. id: multiPriceVariant.id,
  173. prices: [{ currencyCode: CurrencyCode.EUR, price: 1100, delete: true }],
  174. },
  175. });
  176. const { product } = await adminClient.query(getProductWithVariantsDocument, {
  177. id: multiPriceProduct.id,
  178. });
  179. expect(product?.variants[0]?.prices.sort((a, b) => a.price - b.price)).toEqual([
  180. { currencyCode: CurrencyCode.GBP, price: 900 },
  181. { currencyCode: CurrencyCode.USD, price: 1200 },
  182. ]);
  183. });
  184. describe('DefaultProductVariantPriceSelectionStrategy', () => {
  185. it('defaults to default Channel currency', async () => {
  186. const { product } = await adminClient.query(getProductWithVariantsDocument, {
  187. id: multiPriceProduct.id,
  188. });
  189. expect(product?.variants[0]?.price).toEqual(1200);
  190. expect(product?.variants[0]?.priceWithTax).toEqual(1200 * 1.2);
  191. expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.USD);
  192. });
  193. it('uses query string to select currency', async () => {
  194. const { product } = await adminClient.query(
  195. getProductWithVariantsDocument,
  196. {
  197. id: multiPriceProduct.id,
  198. },
  199. { currencyCode: 'GBP' },
  200. );
  201. expect(product?.variants[0]?.price).toEqual(900);
  202. expect(product?.variants[0]?.priceWithTax).toEqual(900 * 1.2);
  203. expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.GBP);
  204. });
  205. it(
  206. 'throws if unrecognised currency code passed in query string',
  207. assertThrowsWithMessage(async () => {
  208. await adminClient.query(
  209. getProductWithVariantsDocument,
  210. {
  211. id: multiPriceProduct.id,
  212. },
  213. { currencyCode: 'JPY' },
  214. );
  215. }, 'The currency "JPY" is not available in the current Channel'),
  216. );
  217. });
  218. describe('changing Order currencyCode', () => {
  219. beforeAll(async () => {
  220. await adminClient.query(updateProductVariantsDocument, {
  221. input: [
  222. {
  223. id: 'T_1',
  224. prices: [
  225. { currencyCode: CurrencyCode.USD, price: 1000 },
  226. { currencyCode: CurrencyCode.GBP, price: 900 },
  227. { currencyCode: CurrencyCode.EUR, price: 1100 },
  228. ],
  229. },
  230. {
  231. id: 'T_2',
  232. prices: [
  233. { currencyCode: CurrencyCode.USD, price: 2000 },
  234. { currencyCode: CurrencyCode.GBP, price: 1900 },
  235. { currencyCode: CurrencyCode.EUR, price: 2100 },
  236. ],
  237. },
  238. {
  239. id: 'T_3',
  240. prices: [
  241. { currencyCode: CurrencyCode.USD, price: 3000 },
  242. { currencyCode: CurrencyCode.GBP, price: 2900 },
  243. { currencyCode: CurrencyCode.EUR, price: 3100 },
  244. ],
  245. },
  246. ],
  247. });
  248. });
  249. it('create order in default currency', async () => {
  250. await shopClient.query(addItemToOrderDocument, {
  251. productVariantId: 'T_1',
  252. quantity: 1,
  253. });
  254. await shopClient.query(addItemToOrderDocument, {
  255. productVariantId: 'T_2',
  256. quantity: 1,
  257. });
  258. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  259. expect(activeOrder?.lines[0]?.unitPrice).toBe(1000);
  260. expect(activeOrder?.lines[0]?.unitPriceWithTax).toBe(1200);
  261. expect(activeOrder?.lines[1]?.unitPrice).toBe(2000);
  262. expect(activeOrder?.lines[1]?.unitPriceWithTax).toBe(2400);
  263. expect(activeOrder?.currencyCode).toBe(CurrencyCode.USD);
  264. });
  265. it(
  266. 'updating an order in an unsupported currency throws',
  267. assertThrowsWithMessage(async () => {
  268. await shopClient.query(
  269. addItemToOrderDocument,
  270. {
  271. productVariantId: 'T_1',
  272. quantity: 1,
  273. },
  274. { currencyCode: 'JPY' },
  275. );
  276. }, 'The currency "JPY" is not available in the current Channel'),
  277. );
  278. it('updating an order line with a new currency updates all lines to that currency', async () => {
  279. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  280. const { adjustOrderLine } = await shopClient.query(
  281. adjustItemQuantityDocument,
  282. {
  283. orderLineId: activeOrder!.lines[0]?.id,
  284. quantity: 2,
  285. },
  286. { currencyCode: 'GBP' },
  287. );
  288. orderResultGuard.assertSuccess(adjustOrderLine);
  289. expect(adjustOrderLine?.lines[0]?.unitPrice).toBe(900);
  290. expect(adjustOrderLine?.lines[0]?.unitPriceWithTax).toBe(1080);
  291. expect(adjustOrderLine?.lines[1]?.unitPrice).toBe(1900);
  292. expect(adjustOrderLine?.lines[1]?.unitPriceWithTax).toBe(2280);
  293. expect(adjustOrderLine.currencyCode).toBe('GBP');
  294. });
  295. it('adding a new order line with a new currency updates all lines to that currency', async () => {
  296. const { addItemToOrder } = await shopClient.query(
  297. addItemToOrderDocument,
  298. {
  299. productVariantId: 'T_3',
  300. quantity: 1,
  301. },
  302. { currencyCode: 'EUR' },
  303. );
  304. orderResultGuard.assertSuccess(addItemToOrder);
  305. expect(addItemToOrder?.lines[0]?.unitPrice).toBe(1100);
  306. expect(addItemToOrder?.lines[0]?.unitPriceWithTax).toBe(1320);
  307. expect(addItemToOrder?.lines[1]?.unitPrice).toBe(2100);
  308. expect(addItemToOrder?.lines[1]?.unitPriceWithTax).toBe(2520);
  309. expect(addItemToOrder?.lines[2]?.unitPrice).toBe(3100);
  310. expect(addItemToOrder?.lines[2]?.unitPriceWithTax).toBe(3720);
  311. expect(addItemToOrder.currencyCode).toBe('EUR');
  312. });
  313. });
  314. describe('ProductVariantPriceUpdateStrategy', () => {
  315. const SECOND_CHANNEL_TOKEN = 'second_channel_token';
  316. const THIRD_CHANNEL_TOKEN = 'third_channel_token';
  317. beforeAll(async () => {
  318. const { createChannel: channel2Result } = await adminClient.query(createChannelDocument, {
  319. input: {
  320. code: 'second-channel',
  321. token: SECOND_CHANNEL_TOKEN,
  322. defaultLanguageCode: LanguageCode.en,
  323. currencyCode: CurrencyCode.GBP,
  324. pricesIncludeTax: true,
  325. defaultShippingZoneId: 'T_1',
  326. defaultTaxZoneId: 'T_1',
  327. },
  328. });
  329. createChannelResultGuard.assertSuccess(channel2Result);
  330. const { createChannel: channel3Result } = await adminClient.query(createChannelDocument, {
  331. input: {
  332. code: 'third-channel',
  333. token: THIRD_CHANNEL_TOKEN,
  334. defaultLanguageCode: LanguageCode.en,
  335. currencyCode: CurrencyCode.GBP,
  336. pricesIncludeTax: true,
  337. defaultShippingZoneId: 'T_1',
  338. defaultTaxZoneId: 'T_1',
  339. },
  340. });
  341. createChannelResultGuard.assertSuccess(channel3Result);
  342. await adminClient.query(assignProductToChannelDocument, {
  343. input: {
  344. channelId: channel2Result.id,
  345. productIds: [multiPriceProduct.id],
  346. },
  347. });
  348. await adminClient.query(assignProductToChannelDocument, {
  349. input: {
  350. channelId: channel3Result.id,
  351. productIds: [multiPriceProduct.id],
  352. },
  353. });
  354. });
  355. it('onPriceCreated() is called when a new price is created', async () => {
  356. await adminClient.asSuperAdmin();
  357. const onCreatedSpy = TestProductVariantPriceUpdateStrategy.onCreatedSpy;
  358. onCreatedSpy.mockClear();
  359. await adminClient.query(updateChannelDocument, {
  360. input: {
  361. id: 'T_1',
  362. availableCurrencyCodes: [
  363. CurrencyCode.USD,
  364. CurrencyCode.GBP,
  365. CurrencyCode.EUR,
  366. CurrencyCode.MYR,
  367. ],
  368. },
  369. });
  370. await adminClient.query(updateProductVariantsDocument, {
  371. input: {
  372. id: multiPriceVariant.id,
  373. prices: [{ currencyCode: CurrencyCode.MYR, price: 5500 }],
  374. },
  375. });
  376. expect(onCreatedSpy).toHaveBeenCalledTimes(1);
  377. expect(onCreatedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.MYR);
  378. expect(onCreatedSpy.mock.calls[0][0].price).toBe(5500);
  379. expect(onCreatedSpy.mock.calls[0][1].length).toBe(4);
  380. expect(getOrderedPricesArray(onCreatedSpy.mock.calls[0][1])).toEqual([
  381. {
  382. channelId: 1,
  383. currencyCode: 'USD',
  384. id: 35,
  385. price: 1200,
  386. },
  387. {
  388. channelId: 1,
  389. currencyCode: 'GBP',
  390. id: 36,
  391. price: 900,
  392. },
  393. {
  394. channelId: 2,
  395. currencyCode: 'GBP',
  396. id: 44,
  397. price: 1440,
  398. },
  399. {
  400. channelId: 3,
  401. currencyCode: 'GBP',
  402. id: 45,
  403. price: 1440,
  404. },
  405. ]);
  406. });
  407. it('onPriceUpdated() is called when a new price is created', async () => {
  408. adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);
  409. TestProductVariantPriceUpdateStrategy.syncAcrossChannels = true;
  410. const onUpdatedSpy = TestProductVariantPriceUpdateStrategy.onUpdatedSpy;
  411. onUpdatedSpy.mockClear();
  412. await adminClient.query(updateProductVariantsDocument, {
  413. input: {
  414. id: multiPriceVariant.id,
  415. prices: [
  416. {
  417. currencyCode: CurrencyCode.GBP,
  418. price: 4242,
  419. },
  420. ],
  421. },
  422. });
  423. expect(onUpdatedSpy).toHaveBeenCalledTimes(1);
  424. expect(onUpdatedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.GBP);
  425. expect(onUpdatedSpy.mock.calls[0][0].price).toBe(4242);
  426. expect(onUpdatedSpy.mock.calls[0][1].length).toBe(5);
  427. expect(getOrderedPricesArray(onUpdatedSpy.mock.calls[0][1])).toEqual([
  428. {
  429. channelId: 1,
  430. currencyCode: 'USD',
  431. id: 35,
  432. price: 1200,
  433. },
  434. {
  435. channelId: 1,
  436. currencyCode: 'GBP',
  437. id: 36,
  438. price: 900,
  439. },
  440. {
  441. channelId: 2,
  442. currencyCode: 'GBP',
  443. id: 44,
  444. price: 1440,
  445. },
  446. {
  447. channelId: 3,
  448. currencyCode: 'GBP',
  449. id: 45,
  450. price: 4242,
  451. },
  452. {
  453. channelId: 1,
  454. currencyCode: 'MYR',
  455. id: 46,
  456. price: 5500,
  457. },
  458. ]);
  459. });
  460. it('syncing prices in other channels', async () => {
  461. const { product: productChannel3 } = await adminClient.query(getProductWithVariantsDocument, {
  462. id: multiPriceProduct.id,
  463. });
  464. expect(productChannel3?.variants[0].prices).toEqual([
  465. { currencyCode: CurrencyCode.GBP, price: 4242 },
  466. ]);
  467. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  468. const { product: productChannel2 } = await adminClient.query(getProductWithVariantsDocument, {
  469. id: multiPriceProduct.id,
  470. });
  471. expect(productChannel2?.variants[0].prices).toEqual([
  472. { currencyCode: CurrencyCode.GBP, price: 4242 },
  473. ]);
  474. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  475. const { product: productDefaultChannel } = await adminClient.query(
  476. getProductWithVariantsDocument,
  477. {
  478. id: multiPriceProduct.id,
  479. },
  480. );
  481. expect(productDefaultChannel?.variants[0].prices).toEqual([
  482. { currencyCode: CurrencyCode.USD, price: 1200 },
  483. { currencyCode: CurrencyCode.GBP, price: 4242 },
  484. { currencyCode: CurrencyCode.MYR, price: 5500 },
  485. ]);
  486. });
  487. it('onPriceDeleted() is called when a price is deleted', async () => {
  488. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  489. const onDeletedSpy = TestProductVariantPriceUpdateStrategy.onDeletedSpy;
  490. onDeletedSpy.mockClear();
  491. const result = await adminClient.query(updateProductVariantsDocument, {
  492. input: {
  493. id: multiPriceVariant.id,
  494. prices: [
  495. {
  496. currencyCode: CurrencyCode.MYR,
  497. price: 4242,
  498. delete: true,
  499. },
  500. ],
  501. },
  502. });
  503. expect(result.updateProductVariants[0]?.prices).toEqual([
  504. { currencyCode: CurrencyCode.USD, price: 1200 },
  505. { currencyCode: CurrencyCode.GBP, price: 4242 },
  506. ]);
  507. expect(onDeletedSpy).toHaveBeenCalledTimes(1);
  508. expect(onDeletedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.MYR);
  509. expect(onDeletedSpy.mock.calls[0][0].price).toBe(5500);
  510. expect(onDeletedSpy.mock.calls[0][1].length).toBe(4);
  511. expect(getOrderedPricesArray(onDeletedSpy.mock.calls[0][1])).toEqual([
  512. {
  513. channelId: 1,
  514. currencyCode: 'USD',
  515. id: 35,
  516. price: 1200,
  517. },
  518. {
  519. channelId: 1,
  520. currencyCode: 'GBP',
  521. id: 36,
  522. price: 4242,
  523. },
  524. {
  525. channelId: 2,
  526. currencyCode: 'GBP',
  527. id: 44,
  528. price: 4242,
  529. },
  530. {
  531. channelId: 3,
  532. currencyCode: 'GBP',
  533. id: 45,
  534. price: 4242,
  535. },
  536. ]);
  537. });
  538. });
  539. });
  540. function getOrderedPricesArray(input: ProductVariantPrice[]) {
  541. return input
  542. .map(p => pick(p, ['channelId', 'currencyCode', 'price', 'id']))
  543. .sort((a, b) => (a.id < b.id ? -1 : 1));
  544. }