shipping-method-eligibility.e2e-spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import { ErrorCode, LanguageCode } from '@vendure/common/lib/generated-types';
  2. import {
  3. defaultShippingCalculator,
  4. defaultShippingEligibilityChecker,
  5. manualFulfillmentHandler,
  6. ShippingCalculator,
  7. ShippingEligibilityChecker,
  8. } from '@vendure/core';
  9. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  10. import path from 'path';
  11. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
  12. import { initialData } from '../../../e2e-common/e2e-initial-data';
  13. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  14. import { FragmentOf, ResultOf } from './graphql/graphql-shop';
  15. import { createShippingMethodDocument } from './graphql/shared-definitions';
  16. import {
  17. addItemToOrderDocument,
  18. adjustItemQuantityDocument,
  19. getActiveOrderDocument,
  20. getEligibleShippingMethodsDocument,
  21. removeItemFromOrderDocument,
  22. setShippingAddressDocument,
  23. setShippingMethodDocument,
  24. testOrderFragment,
  25. updatedOrderFragment,
  26. } from './graphql/shop-definitions';
  27. const check1Spy = vi.fn();
  28. const checker1 = new ShippingEligibilityChecker({
  29. code: 'checker1',
  30. description: [],
  31. args: {},
  32. check: (_ctx, order) => {
  33. check1Spy();
  34. return order.lines.length === 1;
  35. },
  36. });
  37. const check2Spy = vi.fn();
  38. const checker2 = new ShippingEligibilityChecker({
  39. code: 'checker2',
  40. description: [],
  41. args: {},
  42. check: (_ctx, order) => {
  43. check2Spy();
  44. return order.lines.length > 1;
  45. },
  46. });
  47. const check3Spy = vi.fn();
  48. const checker3 = new ShippingEligibilityChecker({
  49. code: 'checker3',
  50. description: [],
  51. args: {},
  52. check: (_ctx, order) => {
  53. check3Spy();
  54. return order.lines.length === 3;
  55. },
  56. shouldRunCheck: (_ctx, order) => {
  57. return order.shippingAddress;
  58. },
  59. });
  60. const calculator = new ShippingCalculator({
  61. code: 'calculator',
  62. description: [],
  63. args: {},
  64. calculate: _ctx => {
  65. return {
  66. price: 10,
  67. priceIncludesTax: false,
  68. taxRate: 20,
  69. };
  70. },
  71. });
  72. describe('ShippingMethod eligibility', () => {
  73. const { server, adminClient, shopClient } = createTestEnvironment({
  74. ...testConfig(),
  75. shippingOptions: {
  76. shippingEligibilityCheckers: [defaultShippingEligibilityChecker, checker1, checker2, checker3],
  77. shippingCalculators: [defaultShippingCalculator, calculator],
  78. },
  79. });
  80. type UpdatedOrderFragment = FragmentOf<typeof updatedOrderFragment>;
  81. const updatedOrderGuard: ErrorResultGuard<UpdatedOrderFragment> = createErrorResultGuard(
  82. input => !!input.lines,
  83. );
  84. type TestOrderFragmentType = FragmentOf<typeof testOrderFragment>;
  85. const testOrderGuard: ErrorResultGuard<TestOrderFragmentType> = createErrorResultGuard(
  86. input => !!input.lines,
  87. );
  88. let singleLineShippingMethod: ResultOf<typeof createShippingMethodDocument>['createShippingMethod'];
  89. let multiLineShippingMethod: ResultOf<typeof createShippingMethodDocument>['createShippingMethod'];
  90. let optimizedShippingMethod: ResultOf<typeof createShippingMethodDocument>['createShippingMethod'];
  91. beforeAll(async () => {
  92. await server.init({
  93. initialData: {
  94. ...initialData,
  95. shippingMethods: [],
  96. },
  97. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  98. customerCount: 1,
  99. });
  100. await adminClient.asSuperAdmin();
  101. const result1 = await adminClient.query(createShippingMethodDocument, {
  102. input: {
  103. code: 'single-line',
  104. fulfillmentHandler: manualFulfillmentHandler.code,
  105. checker: {
  106. code: checker1.code,
  107. arguments: [],
  108. },
  109. calculator: {
  110. code: calculator.code,
  111. arguments: [],
  112. },
  113. translations: [
  114. { languageCode: LanguageCode.en, name: 'For single-line orders', description: '' },
  115. ],
  116. },
  117. });
  118. singleLineShippingMethod = result1.createShippingMethod;
  119. const result2 = await adminClient.query(createShippingMethodDocument, {
  120. input: {
  121. code: 'multi-line',
  122. fulfillmentHandler: manualFulfillmentHandler.code,
  123. checker: {
  124. code: checker2.code,
  125. arguments: [],
  126. },
  127. calculator: {
  128. code: calculator.code,
  129. arguments: [],
  130. },
  131. translations: [
  132. { languageCode: LanguageCode.en, name: 'For multi-line orders', description: '' },
  133. ],
  134. },
  135. });
  136. multiLineShippingMethod = result2.createShippingMethod;
  137. const result3 = await adminClient.query(createShippingMethodDocument, {
  138. input: {
  139. code: 'optimized',
  140. fulfillmentHandler: manualFulfillmentHandler.code,
  141. checker: {
  142. code: checker3.code,
  143. arguments: [],
  144. },
  145. calculator: {
  146. code: calculator.code,
  147. arguments: [],
  148. },
  149. translations: [
  150. { languageCode: LanguageCode.en, name: 'Optimized with shouldRunCheck', description: '' },
  151. ],
  152. },
  153. });
  154. optimizedShippingMethod = result3.createShippingMethod;
  155. }, TEST_SETUP_TIMEOUT_MS);
  156. afterAll(async () => {
  157. await server.destroy();
  158. });
  159. describe('default behavior', () => {
  160. let order: UpdatedOrderFragment;
  161. it('Does not run checkers before a ShippingMethod is assigned to Order', async () => {
  162. check1Spy.mockClear();
  163. check2Spy.mockClear();
  164. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  165. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  166. quantity: 1,
  167. productVariantId: 'T_1',
  168. });
  169. updatedOrderGuard.assertSuccess(addItemToOrder);
  170. expect(check1Spy).not.toHaveBeenCalled();
  171. expect(check2Spy).not.toHaveBeenCalled();
  172. await shopClient.query(adjustItemQuantityDocument, {
  173. quantity: 2,
  174. orderLineId: addItemToOrder.lines[0].id,
  175. });
  176. expect(check1Spy).not.toHaveBeenCalled();
  177. expect(check2Spy).not.toHaveBeenCalled();
  178. order = addItemToOrder;
  179. });
  180. it('Runs checkers when querying for eligible ShippingMethods', async () => {
  181. check1Spy.mockClear();
  182. check2Spy.mockClear();
  183. await shopClient.query(getEligibleShippingMethodsDocument);
  184. expect(check1Spy).toHaveBeenCalledTimes(1);
  185. expect(check2Spy).toHaveBeenCalledTimes(1);
  186. });
  187. it('Runs checker of assigned method only', async () => {
  188. check1Spy.mockClear();
  189. check2Spy.mockClear();
  190. await shopClient.query(setShippingMethodDocument, {
  191. id: [singleLineShippingMethod.id],
  192. });
  193. // A check is done when assigning the method to ensure it
  194. // is eligible, and again when calculating order adjustments
  195. expect(check1Spy).toHaveBeenCalledTimes(2);
  196. expect(check2Spy).not.toHaveBeenCalled();
  197. await shopClient.query(adjustItemQuantityDocument, {
  198. quantity: 3,
  199. orderLineId: order.lines[0].id,
  200. });
  201. expect(check1Spy).toHaveBeenCalledTimes(3);
  202. expect(check2Spy).not.toHaveBeenCalled();
  203. await shopClient.query(adjustItemQuantityDocument, {
  204. quantity: 4,
  205. orderLineId: order.lines[0].id,
  206. });
  207. expect(check1Spy).toHaveBeenCalledTimes(4);
  208. expect(check2Spy).not.toHaveBeenCalled();
  209. });
  210. it('Prevents ineligible method from being assigned', async () => {
  211. const { setOrderShippingMethod } = await shopClient.query(setShippingMethodDocument, {
  212. id: [multiLineShippingMethod.id],
  213. });
  214. testOrderGuard.assertErrorResult(setOrderShippingMethod);
  215. expect(setOrderShippingMethod.errorCode).toBe(ErrorCode.INELIGIBLE_SHIPPING_METHOD_ERROR);
  216. expect(setOrderShippingMethod.message).toBe(
  217. 'This Order is not eligible for the selected ShippingMethod',
  218. );
  219. });
  220. it('Runs checks when assigned method becomes ineligible', async () => {
  221. check1Spy.mockClear();
  222. check2Spy.mockClear();
  223. // Adding a second OrderLine will make the singleLineShippingMethod
  224. // ineligible
  225. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  226. quantity: 1,
  227. productVariantId: 'T_2',
  228. });
  229. updatedOrderGuard.assertSuccess(addItemToOrder);
  230. // Checked once to see if still eligible (no)
  231. expect(check1Spy).toHaveBeenCalledTimes(1);
  232. // Checked once when looking for a fallback
  233. expect(check2Spy).toHaveBeenCalledTimes(1);
  234. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  235. // multiLineShippingMethod assigned as a fallback
  236. expect(activeOrder?.shippingLines?.[0]?.shippingMethod?.id).toBe(multiLineShippingMethod.id);
  237. await shopClient.query(adjustItemQuantityDocument, {
  238. quantity: 2,
  239. orderLineId: addItemToOrder.lines[1].id,
  240. });
  241. // No longer called as singleLineShippingMethod not assigned
  242. expect(check1Spy).toHaveBeenCalledTimes(1);
  243. // Called on changes since multiLineShippingMethod is assigned
  244. expect(check2Spy).toHaveBeenCalledTimes(2);
  245. // Remove the second OrderLine and make multiLineShippingMethod ineligible
  246. const { removeOrderLine } = await shopClient.query(removeItemFromOrderDocument, {
  247. orderLineId: addItemToOrder.lines[1].id,
  248. });
  249. testOrderGuard.assertSuccess(removeOrderLine);
  250. // Called when looking for a fallback
  251. expect(check1Spy).toHaveBeenCalledTimes(2);
  252. // Called when checking if still eligibile (no)
  253. expect(check2Spy).toHaveBeenCalledTimes(3);
  254. // Falls back to the first eligible shipping method
  255. expect(removeOrderLine.shippingLines[0].shippingMethod?.id).toBe(singleLineShippingMethod.id);
  256. });
  257. });
  258. describe('optimization via shouldRunCheck function', () => {
  259. let order: UpdatedOrderFragment;
  260. beforeAll(async () => {
  261. await shopClient.asAnonymousUser();
  262. await shopClient.query(addItemToOrderDocument, {
  263. quantity: 1,
  264. productVariantId: 'T_1',
  265. });
  266. await shopClient.query(addItemToOrderDocument, {
  267. quantity: 1,
  268. productVariantId: 'T_2',
  269. });
  270. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  271. quantity: 1,
  272. productVariantId: 'T_3',
  273. });
  274. updatedOrderGuard.assertSuccess(addItemToOrder);
  275. order = addItemToOrder;
  276. await shopClient.query(setShippingAddressDocument, {
  277. input: {
  278. streetLine1: '42 Test Street',
  279. city: 'Doncaster',
  280. postalCode: 'DN1 4EE',
  281. countryCode: 'GB',
  282. },
  283. });
  284. });
  285. it('runs check on getEligibleShippingMethods', async () => {
  286. check3Spy.mockClear();
  287. await shopClient.query(getEligibleShippingMethodsDocument);
  288. expect(check3Spy).toHaveBeenCalledTimes(1);
  289. });
  290. it('does not re-run check on setting shipping method', async () => {
  291. check3Spy.mockClear();
  292. await shopClient.query(setShippingMethodDocument, {
  293. id: [optimizedShippingMethod.id],
  294. });
  295. expect(check3Spy).toHaveBeenCalledTimes(0);
  296. });
  297. it('does not re-run check when changing cart contents', async () => {
  298. check3Spy.mockClear();
  299. await shopClient.query(adjustItemQuantityDocument, {
  300. quantity: 3,
  301. orderLineId: order.lines[0].id,
  302. });
  303. expect(check3Spy).toHaveBeenCalledTimes(0);
  304. });
  305. it('re-runs check when shouldRunCheck fn invalidates last check', async () => {
  306. check3Spy.mockClear();
  307. // Update the shipping address, causing the `shouldRunCheck` function
  308. // to trigger a check
  309. await shopClient.query(setShippingAddressDocument, {
  310. input: {
  311. streetLine1: '43 Test Street', // This line changed
  312. city: 'Doncaster',
  313. postalCode: 'DN1 4EE',
  314. countryCode: 'GB',
  315. },
  316. });
  317. await shopClient.query(adjustItemQuantityDocument, {
  318. quantity: 2,
  319. orderLineId: order.lines[0].id,
  320. });
  321. expect(check3Spy).toHaveBeenCalledTimes(1);
  322. // Does not check a second time though, since the shipping address
  323. // is now the same as on the last check.
  324. await shopClient.query(adjustItemQuantityDocument, {
  325. quantity: 3,
  326. orderLineId: order.lines[0].id,
  327. });
  328. expect(check3Spy).toHaveBeenCalledTimes(1);
  329. });
  330. });
  331. });