stock-control.e2e-spec.ts 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352
  1. /* tslint:disable:no-non-null-assertion */
  2. import { manualFulfillmentHandler, mergeConfig, OrderState } from '@vendure/core';
  3. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  4. import gql from 'graphql-tag';
  5. import path from 'path';
  6. import { initialData } from '../../../e2e-common/e2e-initial-data';
  7. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  8. import { testSuccessfulPaymentMethod, twoStagePaymentMethod } from './fixtures/test-payment-methods';
  9. import { VARIANT_WITH_STOCK_FRAGMENT } from './graphql/fragments';
  10. import {
  11. CreateAddressInput,
  12. ErrorCode as AdminErrorCode,
  13. FulfillmentFragment,
  14. GlobalFlag,
  15. StockMovementType,
  16. UpdateProductVariantInput,
  17. VariantWithStockFragment,
  18. } from './graphql/generated-e2e-admin-types';
  19. import * as Codegen from './graphql/generated-e2e-admin-types';
  20. import { ErrorCode, PaymentInput } from './graphql/generated-e2e-shop-types';
  21. import * as CodegenShop from './graphql/generated-e2e-shop-types';
  22. import {
  23. CANCEL_ORDER,
  24. CREATE_FULFILLMENT,
  25. GET_ORDER,
  26. GET_STOCK_MOVEMENT,
  27. SETTLE_PAYMENT,
  28. UPDATE_GLOBAL_SETTINGS,
  29. UPDATE_PRODUCT_VARIANTS,
  30. } from './graphql/shared-definitions';
  31. import {
  32. ADD_ITEM_TO_ORDER,
  33. ADD_PAYMENT,
  34. ADJUST_ITEM_QUANTITY,
  35. GET_ACTIVE_ORDER,
  36. GET_ELIGIBLE_SHIPPING_METHODS,
  37. GET_PRODUCT_WITH_STOCK_LEVEL,
  38. SET_SHIPPING_ADDRESS,
  39. SET_SHIPPING_METHOD,
  40. TRANSITION_TO_STATE,
  41. } from './graphql/shop-definitions';
  42. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  43. import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
  44. describe('Stock control', () => {
  45. const { server, adminClient, shopClient } = createTestEnvironment(
  46. mergeConfig(testConfig(), {
  47. paymentOptions: {
  48. paymentMethodHandlers: [testSuccessfulPaymentMethod, twoStagePaymentMethod],
  49. },
  50. customFields: {
  51. OrderLine: [{ name: 'customization', type: 'string', nullable: true }],
  52. },
  53. }),
  54. );
  55. const orderGuard: ErrorResultGuard<
  56. CodegenShop.TestOrderFragmentFragment | CodegenShop.UpdatedOrderFragment
  57. > = createErrorResultGuard(input => !!input.lines);
  58. const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
  59. input => !!input.state,
  60. );
  61. async function getProductWithStockMovement(productId: string) {
  62. const { product } = await adminClient.query<
  63. Codegen.GetStockMovementQuery,
  64. Codegen.GetStockMovementQueryVariables
  65. >(GET_STOCK_MOVEMENT, { id: productId });
  66. return product;
  67. }
  68. async function setFirstEligibleShippingMethod() {
  69. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  70. GET_ELIGIBLE_SHIPPING_METHODS,
  71. );
  72. await shopClient.query<
  73. CodegenShop.SetShippingMethodMutation,
  74. CodegenShop.SetShippingMethodMutationVariables
  75. >(SET_SHIPPING_METHOD, {
  76. id: eligibleShippingMethods[0].id,
  77. });
  78. }
  79. beforeAll(async () => {
  80. await server.init({
  81. initialData: {
  82. ...initialData,
  83. paymentMethods: [
  84. {
  85. name: testSuccessfulPaymentMethod.code,
  86. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  87. },
  88. {
  89. name: twoStagePaymentMethod.code,
  90. handler: { code: twoStagePaymentMethod.code, arguments: [] },
  91. },
  92. ],
  93. },
  94. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
  95. customerCount: 3,
  96. });
  97. await adminClient.asSuperAdmin();
  98. await adminClient.query<
  99. Codegen.UpdateGlobalSettingsMutation,
  100. Codegen.UpdateGlobalSettingsMutationVariables
  101. >(UPDATE_GLOBAL_SETTINGS, {
  102. input: {
  103. trackInventory: false,
  104. },
  105. });
  106. }, TEST_SETUP_TIMEOUT_MS);
  107. afterAll(async () => {
  108. await server.destroy();
  109. });
  110. describe('stock adjustments', () => {
  111. let variants: VariantWithStockFragment[];
  112. it('stockMovements are initially empty', async () => {
  113. const { product } = await adminClient.query<
  114. Codegen.GetStockMovementQuery,
  115. Codegen.GetStockMovementQueryVariables
  116. >(GET_STOCK_MOVEMENT, { id: 'T_1' });
  117. variants = product!.variants;
  118. for (const variant of variants) {
  119. expect(variant.stockMovements.items).toEqual([]);
  120. expect(variant.stockMovements.totalItems).toEqual(0);
  121. }
  122. });
  123. it('updating ProductVariant with same stockOnHand does not create a StockMovement', async () => {
  124. const { updateProductVariants } = await adminClient.query<
  125. Codegen.UpdateStockMutation,
  126. Codegen.UpdateStockMutationVariables
  127. >(UPDATE_STOCK_ON_HAND, {
  128. input: [
  129. {
  130. id: variants[0].id,
  131. stockOnHand: variants[0].stockOnHand,
  132. },
  133. ] as UpdateProductVariantInput[],
  134. });
  135. expect(updateProductVariants[0]!.stockMovements.items).toEqual([]);
  136. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(0);
  137. });
  138. it('increasing stockOnHand creates a StockMovement with correct quantity', async () => {
  139. const { updateProductVariants } = await adminClient.query<
  140. Codegen.UpdateStockMutation,
  141. Codegen.UpdateStockMutationVariables
  142. >(UPDATE_STOCK_ON_HAND, {
  143. input: [
  144. {
  145. id: variants[0].id,
  146. stockOnHand: variants[0].stockOnHand + 5,
  147. },
  148. ] as UpdateProductVariantInput[],
  149. });
  150. expect(updateProductVariants[0]!.stockOnHand).toBe(5);
  151. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(1);
  152. expect(updateProductVariants[0]!.stockMovements.items[0].type).toBe(StockMovementType.ADJUSTMENT);
  153. expect(updateProductVariants[0]!.stockMovements.items[0].quantity).toBe(5);
  154. });
  155. it('decreasing stockOnHand creates a StockMovement with correct quantity', async () => {
  156. const { updateProductVariants } = await adminClient.query<
  157. Codegen.UpdateStockMutation,
  158. Codegen.UpdateStockMutationVariables
  159. >(UPDATE_STOCK_ON_HAND, {
  160. input: [
  161. {
  162. id: variants[0].id,
  163. stockOnHand: variants[0].stockOnHand + 5 - 2,
  164. },
  165. ] as UpdateProductVariantInput[],
  166. });
  167. expect(updateProductVariants[0]!.stockOnHand).toBe(3);
  168. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(2);
  169. expect(updateProductVariants[0]!.stockMovements.items[1].type).toBe(StockMovementType.ADJUSTMENT);
  170. expect(updateProductVariants[0]!.stockMovements.items[1].quantity).toBe(-2);
  171. });
  172. it(
  173. 'attempting to set stockOnHand below saleable stock level throws',
  174. assertThrowsWithMessage(async () => {
  175. const result = await adminClient.query<
  176. Codegen.UpdateStockMutation,
  177. Codegen.UpdateStockMutationVariables
  178. >(UPDATE_STOCK_ON_HAND, {
  179. input: [
  180. {
  181. id: variants[0].id,
  182. stockOnHand: -1,
  183. },
  184. ] as UpdateProductVariantInput[],
  185. });
  186. }, 'stockOnHand cannot be a negative value'),
  187. );
  188. });
  189. describe('sales', () => {
  190. let orderId: string;
  191. beforeAll(async () => {
  192. const product = await getProductWithStockMovement('T_2');
  193. const [variant1, variant2, variant3] = product!.variants;
  194. await adminClient.query<Codegen.UpdateStockMutation, Codegen.UpdateStockMutationVariables>(
  195. UPDATE_STOCK_ON_HAND,
  196. {
  197. input: [
  198. {
  199. id: variant1.id,
  200. stockOnHand: 5,
  201. trackInventory: GlobalFlag.FALSE,
  202. },
  203. {
  204. id: variant2.id,
  205. stockOnHand: 5,
  206. trackInventory: GlobalFlag.TRUE,
  207. },
  208. {
  209. id: variant3.id,
  210. stockOnHand: 5,
  211. trackInventory: GlobalFlag.INHERIT,
  212. },
  213. ] as UpdateProductVariantInput[],
  214. },
  215. );
  216. // Add items to order and check out
  217. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  218. await shopClient.query<
  219. CodegenShop.AddItemToOrderMutation,
  220. CodegenShop.AddItemToOrderMutationVariables
  221. >(ADD_ITEM_TO_ORDER, {
  222. productVariantId: variant1.id,
  223. quantity: 2,
  224. });
  225. await shopClient.query<
  226. CodegenShop.AddItemToOrderMutation,
  227. CodegenShop.AddItemToOrderMutationVariables
  228. >(ADD_ITEM_TO_ORDER, {
  229. productVariantId: variant2.id,
  230. quantity: 3,
  231. });
  232. await shopClient.query<
  233. CodegenShop.AddItemToOrderMutation,
  234. CodegenShop.AddItemToOrderMutationVariables
  235. >(ADD_ITEM_TO_ORDER, {
  236. productVariantId: variant3.id,
  237. quantity: 4,
  238. });
  239. await shopClient.query<
  240. CodegenShop.SetShippingAddressMutation,
  241. CodegenShop.SetShippingAddressMutationVariables
  242. >(SET_SHIPPING_ADDRESS, {
  243. input: {
  244. streetLine1: '1 Test Street',
  245. countryCode: 'GB',
  246. } as CreateAddressInput,
  247. });
  248. await setFirstEligibleShippingMethod();
  249. await shopClient.query<
  250. CodegenShop.TransitionToStateMutation,
  251. CodegenShop.TransitionToStateMutationVariables
  252. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' as OrderState });
  253. });
  254. it('creates an Allocation when order completed', async () => {
  255. const { addPaymentToOrder: order } = await shopClient.query<
  256. CodegenShop.AddPaymentToOrderMutation,
  257. CodegenShop.AddPaymentToOrderMutationVariables
  258. >(ADD_PAYMENT, {
  259. input: {
  260. method: testSuccessfulPaymentMethod.code,
  261. metadata: {},
  262. } as PaymentInput,
  263. });
  264. orderGuard.assertSuccess(order);
  265. expect(order).not.toBeNull();
  266. orderId = order.id;
  267. const product = await getProductWithStockMovement('T_2');
  268. const [variant1, variant2, variant3] = product!.variants;
  269. expect(variant1.stockMovements.totalItems).toBe(2);
  270. expect(variant1.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  271. expect(variant1.stockMovements.items[1].quantity).toBe(2);
  272. expect(variant2.stockMovements.totalItems).toBe(2);
  273. expect(variant2.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  274. expect(variant2.stockMovements.items[1].quantity).toBe(3);
  275. expect(variant3.stockMovements.totalItems).toBe(2);
  276. expect(variant3.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  277. expect(variant3.stockMovements.items[1].quantity).toBe(4);
  278. });
  279. it('stockAllocated is updated according to trackInventory setting', async () => {
  280. const product = await getProductWithStockMovement('T_2');
  281. const [variant1, variant2, variant3] = product!.variants;
  282. // stockOnHand not changed yet
  283. expect(variant1.stockOnHand).toBe(5);
  284. expect(variant2.stockOnHand).toBe(5);
  285. expect(variant3.stockOnHand).toBe(5);
  286. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  287. expect(variant2.stockAllocated).toBe(3); // tracked inventory
  288. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  289. });
  290. it('creates a Release on cancelling an allocated OrderItem and updates stockAllocated', async () => {
  291. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  292. GET_ORDER,
  293. {
  294. id: orderId,
  295. },
  296. );
  297. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  298. CANCEL_ORDER,
  299. {
  300. input: {
  301. orderId: order!.id,
  302. lines: [{ orderLineId: order!.lines.find(l => l.quantity === 3)!.id, quantity: 1 }],
  303. reason: 'Not needed',
  304. },
  305. },
  306. );
  307. const product = await getProductWithStockMovement('T_2');
  308. const [_, variant2, __] = product!.variants;
  309. expect(variant2.stockMovements.totalItems).toBe(3);
  310. expect(variant2.stockMovements.items[2].type).toBe(StockMovementType.RELEASE);
  311. expect(variant2.stockMovements.items[2].quantity).toBe(1);
  312. expect(variant2.stockAllocated).toBe(2);
  313. });
  314. it('creates a Sale on Fulfillment creation', async () => {
  315. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  316. GET_ORDER,
  317. {
  318. id: orderId,
  319. },
  320. );
  321. await adminClient.query<
  322. Codegen.CreateFulfillmentMutation,
  323. Codegen.CreateFulfillmentMutationVariables
  324. >(CREATE_FULFILLMENT, {
  325. input: {
  326. lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
  327. handler: {
  328. code: manualFulfillmentHandler.code,
  329. arguments: [
  330. { name: 'method', value: 'test method' },
  331. { name: 'trackingCode', value: 'ABC123' },
  332. ],
  333. },
  334. },
  335. });
  336. const product = await getProductWithStockMovement('T_2');
  337. const [variant1, variant2, variant3] = product!.variants;
  338. expect(variant1.stockMovements.totalItems).toBe(3);
  339. expect(variant1.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  340. expect(variant1.stockMovements.items[2].quantity).toBe(-2);
  341. // 4 rather than 3 since a Release was created in the previous test
  342. expect(variant2.stockMovements.totalItems).toBe(4);
  343. expect(variant2.stockMovements.items[3].type).toBe(StockMovementType.SALE);
  344. expect(variant2.stockMovements.items[3].quantity).toBe(-2);
  345. expect(variant3.stockMovements.totalItems).toBe(3);
  346. expect(variant3.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  347. expect(variant3.stockMovements.items[2].quantity).toBe(-4);
  348. });
  349. it('updates stockOnHand and stockAllocated when Sales are created', async () => {
  350. const product = await getProductWithStockMovement('T_2');
  351. const [variant1, variant2, variant3] = product!.variants;
  352. expect(variant1.stockOnHand).toBe(5); // untracked inventory
  353. expect(variant2.stockOnHand).toBe(3); // tracked inventory
  354. expect(variant3.stockOnHand).toBe(5); // inherited untracked inventory
  355. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  356. expect(variant2.stockAllocated).toBe(0); // tracked inventory
  357. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  358. });
  359. it('creates Cancellations when cancelling items which are part of a Fulfillment', async () => {
  360. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  361. GET_ORDER,
  362. {
  363. id: orderId,
  364. },
  365. );
  366. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  367. CANCEL_ORDER,
  368. {
  369. input: {
  370. orderId: order!.id,
  371. lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  372. reason: 'Faulty',
  373. },
  374. },
  375. );
  376. const product = await getProductWithStockMovement('T_2');
  377. const [variant1, variant2, variant3] = product!.variants;
  378. expect(variant1.stockMovements.totalItems).toBe(5);
  379. expect(variant1.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  380. expect(variant1.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  381. expect(variant2.stockMovements.totalItems).toBe(6);
  382. expect(variant2.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  383. expect(variant2.stockMovements.items[5].type).toBe(StockMovementType.CANCELLATION);
  384. expect(variant3.stockMovements.totalItems).toBe(7);
  385. expect(variant3.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  386. expect(variant3.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  387. expect(variant3.stockMovements.items[5].type).toBe(StockMovementType.CANCELLATION);
  388. expect(variant3.stockMovements.items[6].type).toBe(StockMovementType.CANCELLATION);
  389. });
  390. // https://github.com/vendure-ecommerce/vendure/issues/1198
  391. it('creates Cancellations & adjusts stock when cancelling a Fulfillment', async () => {
  392. async function getTrackedVariant() {
  393. const result = await getProductWithStockMovement('T_2');
  394. return result?.variants[1]!;
  395. }
  396. const trackedVariant1 = await getTrackedVariant();
  397. expect(trackedVariant1.stockOnHand).toBe(5);
  398. // Add items to order and check out
  399. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  400. await shopClient.query<
  401. CodegenShop.AddItemToOrderMutation,
  402. CodegenShop.AddItemToOrderMutationVariables
  403. >(ADD_ITEM_TO_ORDER, {
  404. productVariantId: trackedVariant1.id,
  405. quantity: 1,
  406. });
  407. await shopClient.query<
  408. CodegenShop.SetShippingAddressMutation,
  409. CodegenShop.SetShippingAddressMutationVariables
  410. >(SET_SHIPPING_ADDRESS, {
  411. input: {
  412. streetLine1: '1 Test Street',
  413. countryCode: 'GB',
  414. } as CreateAddressInput,
  415. });
  416. await setFirstEligibleShippingMethod();
  417. await shopClient.query<
  418. CodegenShop.TransitionToStateMutation,
  419. CodegenShop.TransitionToStateMutationVariables
  420. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' as OrderState });
  421. const { addPaymentToOrder: order } = await shopClient.query<
  422. CodegenShop.AddPaymentToOrderMutation,
  423. CodegenShop.AddPaymentToOrderMutationVariables
  424. >(ADD_PAYMENT, {
  425. input: {
  426. method: testSuccessfulPaymentMethod.code,
  427. metadata: {},
  428. } as PaymentInput,
  429. });
  430. orderGuard.assertSuccess(order);
  431. expect(order).not.toBeNull();
  432. const trackedVariant2 = await getTrackedVariant();
  433. expect(trackedVariant2.stockOnHand).toBe(5);
  434. expect(trackedVariant2.stockAllocated).toBe(1);
  435. const linesInput =
  436. order?.lines
  437. .filter(l => l.productVariant.id === trackedVariant2.id)
  438. .map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [];
  439. const { addFulfillmentToOrder } = await adminClient.query<
  440. Codegen.CreateFulfillmentMutation,
  441. Codegen.CreateFulfillmentMutationVariables
  442. >(CREATE_FULFILLMENT, {
  443. input: {
  444. lines: linesInput,
  445. handler: {
  446. code: manualFulfillmentHandler.code,
  447. arguments: [
  448. { name: 'method', value: 'test method' },
  449. { name: 'trackingCode', value: 'ABC123' },
  450. ],
  451. },
  452. },
  453. });
  454. const trackedVariant3 = await getTrackedVariant();
  455. expect(trackedVariant3.stockOnHand).toBe(4);
  456. expect(trackedVariant3.stockAllocated).toBe(0);
  457. const { transitionFulfillmentToState } = await adminClient.query<
  458. Codegen.TransitionFulfillmentToStateMutation,
  459. Codegen.TransitionFulfillmentToStateMutationVariables
  460. >(TRANSITION_FULFILLMENT_TO_STATE, {
  461. state: 'Cancelled',
  462. id: (addFulfillmentToOrder as any).id,
  463. });
  464. const trackedVariant4 = await getTrackedVariant();
  465. expect(trackedVariant4.stockOnHand).toBe(5);
  466. expect(trackedVariant4.stockAllocated).toBe(1);
  467. expect(trackedVariant4.stockMovements.items).toEqual([
  468. { id: 'T_4', quantity: 5, type: 'ADJUSTMENT' },
  469. { id: 'T_7', quantity: 3, type: 'ALLOCATION' },
  470. { id: 'T_9', quantity: 1, type: 'RELEASE' },
  471. { id: 'T_11', quantity: -2, type: 'SALE' },
  472. { id: 'T_15', quantity: 1, type: 'CANCELLATION' },
  473. { id: 'T_16', quantity: 1, type: 'CANCELLATION' },
  474. { id: 'T_21', quantity: 1, type: 'ALLOCATION' },
  475. { id: 'T_22', quantity: -1, type: 'SALE' },
  476. // This is the cancellation & allocation we are testing for
  477. { id: 'T_23', quantity: 1, type: 'CANCELLATION' },
  478. { id: 'T_24', quantity: 1, type: 'ALLOCATION' },
  479. ]);
  480. const { cancelOrder } = await adminClient.query<
  481. Codegen.CancelOrderMutation,
  482. Codegen.CancelOrderMutationVariables
  483. >(CANCEL_ORDER, {
  484. input: {
  485. orderId: order!.id,
  486. reason: 'Not needed',
  487. },
  488. });
  489. orderGuard.assertSuccess(cancelOrder);
  490. const trackedVariant5 = await getTrackedVariant();
  491. expect(trackedVariant5.stockOnHand).toBe(5);
  492. expect(trackedVariant5.stockAllocated).toBe(0);
  493. });
  494. });
  495. describe('saleable stock level', () => {
  496. let order: CodegenShop.TestOrderWithPaymentsFragment;
  497. beforeAll(async () => {
  498. await adminClient.query<
  499. Codegen.UpdateGlobalSettingsMutation,
  500. Codegen.UpdateGlobalSettingsMutationVariables
  501. >(UPDATE_GLOBAL_SETTINGS, {
  502. input: {
  503. trackInventory: true,
  504. outOfStockThreshold: -5,
  505. },
  506. });
  507. await adminClient.query<
  508. Codegen.UpdateProductVariantsMutation,
  509. Codegen.UpdateProductVariantsMutationVariables
  510. >(UPDATE_PRODUCT_VARIANTS, {
  511. input: [
  512. {
  513. id: 'T_1',
  514. stockOnHand: 3,
  515. outOfStockThreshold: 0,
  516. trackInventory: GlobalFlag.TRUE,
  517. useGlobalOutOfStockThreshold: false,
  518. },
  519. {
  520. id: 'T_2',
  521. stockOnHand: 3,
  522. outOfStockThreshold: 0,
  523. trackInventory: GlobalFlag.FALSE,
  524. useGlobalOutOfStockThreshold: false,
  525. },
  526. {
  527. id: 'T_3',
  528. stockOnHand: 3,
  529. outOfStockThreshold: 2,
  530. trackInventory: GlobalFlag.TRUE,
  531. useGlobalOutOfStockThreshold: false,
  532. },
  533. {
  534. id: 'T_4',
  535. stockOnHand: 3,
  536. outOfStockThreshold: 0,
  537. trackInventory: GlobalFlag.TRUE,
  538. useGlobalOutOfStockThreshold: true,
  539. },
  540. {
  541. id: 'T_5',
  542. stockOnHand: 0,
  543. outOfStockThreshold: 0,
  544. trackInventory: GlobalFlag.TRUE,
  545. useGlobalOutOfStockThreshold: false,
  546. },
  547. ],
  548. });
  549. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  550. });
  551. it('stockLevel uses DefaultStockDisplayStrategy', async () => {
  552. const { product } = await shopClient.query<
  553. CodegenShop.GetProductStockLevelQuery,
  554. CodegenShop.GetProductStockLevelQueryVariables
  555. >(GET_PRODUCT_WITH_STOCK_LEVEL, {
  556. id: 'T_2',
  557. });
  558. expect(product?.variants.map(v => v.stockLevel)).toEqual([
  559. 'OUT_OF_STOCK',
  560. 'IN_STOCK',
  561. 'IN_STOCK',
  562. ]);
  563. });
  564. it('does not add an empty OrderLine if zero saleable stock', async () => {
  565. const variantId = 'T_5';
  566. const { addItemToOrder } = await shopClient.query<
  567. CodegenShop.AddItemToOrderMutation,
  568. CodegenShop.AddItemToOrderMutationVariables
  569. >(ADD_ITEM_TO_ORDER, {
  570. productVariantId: variantId,
  571. quantity: 1,
  572. });
  573. orderGuard.assertErrorResult(addItemToOrder);
  574. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  575. expect(addItemToOrder.message).toBe(`No items were added to the order due to insufficient stock`);
  576. expect((addItemToOrder as any).quantityAvailable).toBe(0);
  577. expect((addItemToOrder as any).order.lines.length).toBe(0);
  578. });
  579. it('returns InsufficientStockError when tracking inventory & adding too many at once', async () => {
  580. const variantId = 'T_1';
  581. const { addItemToOrder } = await shopClient.query<
  582. CodegenShop.AddItemToOrderMutation,
  583. CodegenShop.AddItemToOrderMutationVariables
  584. >(ADD_ITEM_TO_ORDER, {
  585. productVariantId: variantId,
  586. quantity: 5,
  587. });
  588. orderGuard.assertErrorResult(addItemToOrder);
  589. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  590. expect(addItemToOrder.message).toBe(
  591. `Only 3 items were added to the order due to insufficient stock`,
  592. );
  593. expect((addItemToOrder as any).quantityAvailable).toBe(3);
  594. // Still adds as many as available to the Order
  595. expect((addItemToOrder as any).order.lines[0].productVariant.id).toBe(variantId);
  596. expect((addItemToOrder as any).order.lines[0].quantity).toBe(3);
  597. const product = await getProductWithStockMovement('T_1');
  598. const variant = product!.variants[0];
  599. expect(variant.id).toBe(variantId);
  600. expect(variant.stockAllocated).toBe(0);
  601. expect(variant.stockOnHand).toBe(3);
  602. });
  603. it('does not return error when not tracking inventory', async () => {
  604. const variantId = 'T_2';
  605. const { addItemToOrder } = await shopClient.query<
  606. CodegenShop.AddItemToOrderMutation,
  607. CodegenShop.AddItemToOrderMutationVariables
  608. >(ADD_ITEM_TO_ORDER, {
  609. productVariantId: variantId,
  610. quantity: 5,
  611. });
  612. orderGuard.assertSuccess(addItemToOrder);
  613. expect(addItemToOrder.lines.length).toBe(2);
  614. expect(addItemToOrder.lines[1].productVariant.id).toBe(variantId);
  615. expect(addItemToOrder.lines[1].quantity).toBe(5);
  616. const product = await getProductWithStockMovement('T_1');
  617. const variant = product!.variants[1];
  618. expect(variant.id).toBe(variantId);
  619. expect(variant.stockAllocated).toBe(0);
  620. expect(variant.stockOnHand).toBe(3);
  621. });
  622. it('returns InsufficientStockError for positive threshold', async () => {
  623. const variantId = 'T_3';
  624. const { addItemToOrder } = await shopClient.query<
  625. CodegenShop.AddItemToOrderMutation,
  626. CodegenShop.AddItemToOrderMutationVariables
  627. >(ADD_ITEM_TO_ORDER, {
  628. productVariantId: variantId,
  629. quantity: 2,
  630. });
  631. orderGuard.assertErrorResult(addItemToOrder);
  632. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  633. expect(addItemToOrder.message).toBe(
  634. `Only 1 item was added to the order due to insufficient stock`,
  635. );
  636. expect((addItemToOrder as any).quantityAvailable).toBe(1);
  637. // Still adds as many as available to the Order
  638. expect((addItemToOrder as any).order.lines.length).toBe(3);
  639. expect((addItemToOrder as any).order.lines[2].productVariant.id).toBe(variantId);
  640. expect((addItemToOrder as any).order.lines[2].quantity).toBe(1);
  641. const product = await getProductWithStockMovement('T_1');
  642. const variant = product!.variants[2];
  643. expect(variant.id).toBe(variantId);
  644. expect(variant.stockAllocated).toBe(0);
  645. expect(variant.stockOnHand).toBe(3);
  646. });
  647. it('negative threshold allows backorder', async () => {
  648. const variantId = 'T_4';
  649. const { addItemToOrder } = await shopClient.query<
  650. CodegenShop.AddItemToOrderMutation,
  651. CodegenShop.AddItemToOrderMutationVariables
  652. >(ADD_ITEM_TO_ORDER, {
  653. productVariantId: variantId,
  654. quantity: 8,
  655. });
  656. orderGuard.assertSuccess(addItemToOrder);
  657. expect(addItemToOrder.lines.length).toBe(4);
  658. expect(addItemToOrder.lines[3].productVariant.id).toBe(variantId);
  659. expect(addItemToOrder.lines[3].quantity).toBe(8);
  660. const product = await getProductWithStockMovement('T_1');
  661. const variant = product!.variants[3];
  662. expect(variant.id).toBe(variantId);
  663. expect(variant.stockAllocated).toBe(0);
  664. expect(variant.stockOnHand).toBe(3);
  665. });
  666. it('allocates stock', async () => {
  667. await proceedToArrangingPayment(shopClient);
  668. const result = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
  669. orderGuard.assertSuccess(result);
  670. order = result;
  671. const product = await getProductWithStockMovement('T_1');
  672. const [variant1, variant2, variant3, variant4] = product!.variants;
  673. expect(variant1.stockAllocated).toBe(3);
  674. expect(variant1.stockOnHand).toBe(3);
  675. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  676. expect(variant2.stockOnHand).toBe(3);
  677. expect(variant3.stockAllocated).toBe(1);
  678. expect(variant3.stockOnHand).toBe(3);
  679. expect(variant4.stockAllocated).toBe(8);
  680. expect(variant4.stockOnHand).toBe(3);
  681. });
  682. it('does not re-allocate stock when transitioning Payment from Authorized -> Settled', async () => {
  683. await adminClient.query<Codegen.SettlePaymentMutation, Codegen.SettlePaymentMutationVariables>(
  684. SETTLE_PAYMENT,
  685. {
  686. id: order.id,
  687. },
  688. );
  689. const product = await getProductWithStockMovement('T_1');
  690. const [variant1, variant2, variant3, variant4] = product!.variants;
  691. expect(variant1.stockAllocated).toBe(3);
  692. expect(variant1.stockOnHand).toBe(3);
  693. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  694. expect(variant2.stockOnHand).toBe(3);
  695. expect(variant3.stockAllocated).toBe(1);
  696. expect(variant3.stockOnHand).toBe(3);
  697. expect(variant4.stockAllocated).toBe(8);
  698. expect(variant4.stockOnHand).toBe(3);
  699. });
  700. it('addFulfillmentToOrder returns ErrorResult when insufficient stock on hand', async () => {
  701. const { addFulfillmentToOrder } = await adminClient.query<
  702. Codegen.CreateFulfillmentMutation,
  703. Codegen.CreateFulfillmentMutationVariables
  704. >(CREATE_FULFILLMENT, {
  705. input: {
  706. lines: order.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  707. handler: {
  708. code: manualFulfillmentHandler.code,
  709. arguments: [
  710. { name: 'method', value: 'test method' },
  711. { name: 'trackingCode', value: 'ABC123' },
  712. ],
  713. },
  714. },
  715. });
  716. fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
  717. expect(addFulfillmentToOrder.errorCode).toBe(AdminErrorCode.INSUFFICIENT_STOCK_ON_HAND_ERROR);
  718. expect(addFulfillmentToOrder.message).toBe(
  719. `Cannot create a Fulfillment as 'Laptop 15 inch 16GB' has insufficient stockOnHand (3)`,
  720. );
  721. });
  722. it('addFulfillmentToOrder succeeds when there is sufficient stockOnHand', async () => {
  723. const { addFulfillmentToOrder } = await adminClient.query<
  724. Codegen.CreateFulfillmentMutation,
  725. Codegen.CreateFulfillmentMutationVariables
  726. >(CREATE_FULFILLMENT, {
  727. input: {
  728. lines: order.lines
  729. .filter(l => l.productVariant.id === 'T_1')
  730. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  731. handler: {
  732. code: manualFulfillmentHandler.code,
  733. arguments: [
  734. { name: 'method', value: 'test method' },
  735. { name: 'trackingCode', value: 'ABC123' },
  736. ],
  737. },
  738. },
  739. });
  740. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  741. const product = await getProductWithStockMovement('T_1');
  742. const variant = product!.variants[0];
  743. expect(variant.stockOnHand).toBe(0);
  744. expect(variant.stockAllocated).toBe(0);
  745. });
  746. it('addFulfillmentToOrder succeeds when inventory is not being tracked', async () => {
  747. const { addFulfillmentToOrder } = await adminClient.query<
  748. Codegen.CreateFulfillmentMutation,
  749. Codegen.CreateFulfillmentMutationVariables
  750. >(CREATE_FULFILLMENT, {
  751. input: {
  752. lines: order.lines
  753. .filter(l => l.productVariant.id === 'T_2')
  754. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  755. handler: {
  756. code: manualFulfillmentHandler.code,
  757. arguments: [
  758. { name: 'method', value: 'test method' },
  759. { name: 'trackingCode', value: 'ABC123' },
  760. ],
  761. },
  762. },
  763. });
  764. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  765. const product = await getProductWithStockMovement('T_1');
  766. const variant = product!.variants[1];
  767. expect(variant.stockOnHand).toBe(3);
  768. expect(variant.stockAllocated).toBe(0);
  769. });
  770. it('addFulfillmentToOrder succeeds when making a partial Fulfillment with quantity equal to stockOnHand', async () => {
  771. const { addFulfillmentToOrder } = await adminClient.query<
  772. Codegen.CreateFulfillmentMutation,
  773. Codegen.CreateFulfillmentMutationVariables
  774. >(CREATE_FULFILLMENT, {
  775. input: {
  776. lines: order.lines
  777. .filter(l => l.productVariant.id === 'T_4')
  778. .map(l => ({ orderLineId: l.id, quantity: 3 })), // we know there are only 3 on hand
  779. handler: {
  780. code: manualFulfillmentHandler.code,
  781. arguments: [
  782. { name: 'method', value: 'test method' },
  783. { name: 'trackingCode', value: 'ABC123' },
  784. ],
  785. },
  786. },
  787. });
  788. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  789. const product = await getProductWithStockMovement('T_1');
  790. const variant = product!.variants[3];
  791. expect(variant.stockOnHand).toBe(0);
  792. expect(variant.stockAllocated).toBe(5);
  793. });
  794. it('fulfillment can be created after adjusting stockOnHand to be sufficient', async () => {
  795. const { updateProductVariants } = await adminClient.query<
  796. Codegen.UpdateProductVariantsMutation,
  797. Codegen.UpdateProductVariantsMutationVariables
  798. >(UPDATE_PRODUCT_VARIANTS, {
  799. input: [
  800. {
  801. id: 'T_4',
  802. stockOnHand: 10,
  803. },
  804. ],
  805. });
  806. expect(updateProductVariants[0]!.stockOnHand).toBe(10);
  807. const { addFulfillmentToOrder } = await adminClient.query<
  808. Codegen.CreateFulfillmentMutation,
  809. Codegen.CreateFulfillmentMutationVariables
  810. >(CREATE_FULFILLMENT, {
  811. input: {
  812. lines: order.lines
  813. .filter(l => l.productVariant.id === 'T_4')
  814. .map(l => ({ orderLineId: l.id, quantity: 5 })),
  815. handler: {
  816. code: manualFulfillmentHandler.code,
  817. arguments: [
  818. { name: 'method', value: 'test method' },
  819. { name: 'trackingCode', value: 'ABC123' },
  820. ],
  821. },
  822. },
  823. });
  824. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  825. const product = await getProductWithStockMovement('T_1');
  826. const variant = product!.variants[3];
  827. expect(variant.stockOnHand).toBe(5);
  828. expect(variant.stockAllocated).toBe(0);
  829. });
  830. describe('adjusting stockOnHand with negative outOfStockThreshold', () => {
  831. const variant1Id = 'T_1';
  832. beforeAll(async () => {
  833. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  834. UPDATE_PRODUCT_VARIANTS,
  835. {
  836. input: [
  837. {
  838. id: variant1Id,
  839. stockOnHand: 0,
  840. outOfStockThreshold: -20,
  841. trackInventory: GlobalFlag.TRUE,
  842. useGlobalOutOfStockThreshold: false,
  843. },
  844. ],
  845. },
  846. );
  847. });
  848. it(
  849. 'attempting to set stockOnHand below outOfStockThreshold throws',
  850. assertThrowsWithMessage(async () => {
  851. const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
  852. UPDATE_STOCK_ON_HAND,
  853. {
  854. input: [
  855. {
  856. id: variant1Id,
  857. stockOnHand: -21,
  858. },
  859. ] as UpdateProductVariantInput[],
  860. },
  861. );
  862. }, 'stockOnHand cannot be a negative value'),
  863. );
  864. it('can set negative stockOnHand that is not less than outOfStockThreshold', async () => {
  865. const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
  866. UPDATE_STOCK_ON_HAND,
  867. {
  868. input: [
  869. {
  870. id: variant1Id,
  871. stockOnHand: -10,
  872. },
  873. ] as UpdateProductVariantInput[],
  874. },
  875. );
  876. expect(result.updateProductVariants[0]!.stockOnHand).toBe(-10);
  877. });
  878. });
  879. describe('edge cases', () => {
  880. const variant5Id = 'T_5';
  881. const variant6Id = 'T_6';
  882. const variant7Id = 'T_7';
  883. beforeAll(async () => {
  884. // First place an order which creates a backorder (excess of allocated units)
  885. await adminClient.query<
  886. Codegen.UpdateProductVariantsMutation,
  887. Codegen.UpdateProductVariantsMutationVariables
  888. >(UPDATE_PRODUCT_VARIANTS, {
  889. input: [
  890. {
  891. id: variant5Id,
  892. stockOnHand: 5,
  893. outOfStockThreshold: -20,
  894. trackInventory: GlobalFlag.TRUE,
  895. useGlobalOutOfStockThreshold: false,
  896. },
  897. {
  898. id: variant6Id,
  899. stockOnHand: 3,
  900. outOfStockThreshold: 0,
  901. trackInventory: GlobalFlag.TRUE,
  902. useGlobalOutOfStockThreshold: false,
  903. },
  904. {
  905. id: variant7Id,
  906. stockOnHand: 3,
  907. outOfStockThreshold: 0,
  908. trackInventory: GlobalFlag.TRUE,
  909. useGlobalOutOfStockThreshold: false,
  910. },
  911. ],
  912. });
  913. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  914. const { addItemToOrder: add1 } = await shopClient.query<
  915. CodegenShop.AddItemToOrderMutation,
  916. CodegenShop.AddItemToOrderMutationVariables
  917. >(ADD_ITEM_TO_ORDER, {
  918. productVariantId: variant5Id,
  919. quantity: 25,
  920. });
  921. orderGuard.assertSuccess(add1);
  922. await proceedToArrangingPayment(shopClient);
  923. await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  924. });
  925. it('zero saleable stock', async () => {
  926. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  927. // The saleable stock level is now 0 (25 allocated, 5 on hand, -20 threshold)
  928. const { addItemToOrder } = await shopClient.query<
  929. CodegenShop.AddItemToOrderMutation,
  930. CodegenShop.AddItemToOrderMutationVariables
  931. >(ADD_ITEM_TO_ORDER, {
  932. productVariantId: variant5Id,
  933. quantity: 1,
  934. });
  935. orderGuard.assertErrorResult(addItemToOrder);
  936. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  937. expect(addItemToOrder.message).toBe(
  938. `No items were added to the order due to insufficient stock`,
  939. );
  940. });
  941. it('negative saleable stock', async () => {
  942. await adminClient.query<
  943. Codegen.UpdateProductVariantsMutation,
  944. Codegen.UpdateProductVariantsMutationVariables
  945. >(UPDATE_PRODUCT_VARIANTS, {
  946. input: [
  947. {
  948. id: variant5Id,
  949. outOfStockThreshold: -10,
  950. },
  951. ],
  952. });
  953. // The saleable stock level is now -10 (25 allocated, 5 on hand, -10 threshold)
  954. await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
  955. const { addItemToOrder } = await shopClient.query<
  956. CodegenShop.AddItemToOrderMutation,
  957. CodegenShop.AddItemToOrderMutationVariables
  958. >(ADD_ITEM_TO_ORDER, {
  959. productVariantId: variant5Id,
  960. quantity: 1,
  961. });
  962. orderGuard.assertErrorResult(addItemToOrder);
  963. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  964. expect(addItemToOrder.message).toBe(
  965. `No items were added to the order due to insufficient stock`,
  966. );
  967. });
  968. // https://github.com/vendure-ecommerce/vendure/issues/691
  969. it('returns InsufficientStockError when tracking inventory & adding too many individually', async () => {
  970. await shopClient.asAnonymousUser();
  971. const { addItemToOrder: add1 } = await shopClient.query<
  972. CodegenShop.AddItemToOrderMutation,
  973. CodegenShop.AddItemToOrderMutationVariables
  974. >(ADD_ITEM_TO_ORDER, {
  975. productVariantId: variant6Id,
  976. quantity: 3,
  977. });
  978. orderGuard.assertSuccess(add1);
  979. const { addItemToOrder: add2 } = await shopClient.query<
  980. CodegenShop.AddItemToOrderMutation,
  981. CodegenShop.AddItemToOrderMutationVariables
  982. >(ADD_ITEM_TO_ORDER, {
  983. productVariantId: variant6Id,
  984. quantity: 1,
  985. });
  986. orderGuard.assertErrorResult(add2);
  987. expect(add2.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  988. expect(add2.message).toBe(`No items were added to the order due to insufficient stock`);
  989. expect((add2 as any).quantityAvailable).toBe(0);
  990. // Still adds as many as available to the Order
  991. expect((add2 as any).order.lines[0].productVariant.id).toBe(variant6Id);
  992. expect((add2 as any).order.lines[0].quantity).toBe(3);
  993. });
  994. // https://github.com/vendure-ecommerce/vendure/issues/1273
  995. it('adjustOrderLine when saleable stock changes to zero', async () => {
  996. await adminClient.query<
  997. Codegen.UpdateProductVariantsMutation,
  998. Codegen.UpdateProductVariantsMutationVariables
  999. >(UPDATE_PRODUCT_VARIANTS, {
  1000. input: [
  1001. {
  1002. id: variant7Id,
  1003. stockOnHand: 10,
  1004. },
  1005. ],
  1006. });
  1007. await shopClient.asAnonymousUser();
  1008. const { addItemToOrder: add1 } = await shopClient.query<
  1009. CodegenShop.AddItemToOrderMutation,
  1010. CodegenShop.AddItemToOrderMutationVariables
  1011. >(ADD_ITEM_TO_ORDER, {
  1012. productVariantId: variant7Id,
  1013. quantity: 1,
  1014. });
  1015. orderGuard.assertSuccess(add1);
  1016. expect(add1.lines.length).toBe(1);
  1017. await adminClient.query<
  1018. Codegen.UpdateProductVariantsMutation,
  1019. Codegen.UpdateProductVariantsMutationVariables
  1020. >(UPDATE_PRODUCT_VARIANTS, {
  1021. input: [
  1022. {
  1023. id: variant7Id,
  1024. stockOnHand: 0,
  1025. },
  1026. ],
  1027. });
  1028. const { adjustOrderLine: add2 } = await shopClient.query<
  1029. CodegenShop.AdjustItemQuantityMutation,
  1030. CodegenShop.AdjustItemQuantityMutationVariables
  1031. >(ADJUST_ITEM_QUANTITY, {
  1032. orderLineId: add1.lines[0].id,
  1033. quantity: 2,
  1034. });
  1035. orderGuard.assertErrorResult(add2);
  1036. expect(add2.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  1037. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
  1038. GET_ACTIVE_ORDER,
  1039. );
  1040. expect(activeOrder!.lines.length).toBe(0);
  1041. });
  1042. });
  1043. });
  1044. // https://github.com/vendure-ecommerce/vendure/issues/1028
  1045. describe('OrderLines with same variant but different custom fields', () => {
  1046. let orderId: string;
  1047. const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = `
  1048. mutation AddItemToOrderWithCustomFields(
  1049. $productVariantId: ID!
  1050. $quantity: Int!
  1051. $customFields: OrderLineCustomFieldsInput
  1052. ) {
  1053. addItemToOrder(
  1054. productVariantId: $productVariantId
  1055. quantity: $quantity
  1056. customFields: $customFields
  1057. ) {
  1058. ... on Order {
  1059. id
  1060. lines { id }
  1061. }
  1062. ... on ErrorResult {
  1063. errorCode
  1064. message
  1065. }
  1066. }
  1067. }
  1068. `;
  1069. it('correctly allocates stock', async () => {
  1070. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  1071. const product = await getProductWithStockMovement('T_2');
  1072. const [variant1, variant2, variant3] = product!.variants;
  1073. expect(variant2.stockAllocated).toBe(0);
  1074. await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
  1075. gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS),
  1076. {
  1077. productVariantId: variant2.id,
  1078. quantity: 1,
  1079. customFields: {
  1080. customization: 'foo',
  1081. },
  1082. },
  1083. );
  1084. const { addItemToOrder } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
  1085. gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS),
  1086. {
  1087. productVariantId: variant2.id,
  1088. quantity: 1,
  1089. customFields: {
  1090. customization: 'bar',
  1091. },
  1092. },
  1093. );
  1094. orderGuard.assertSuccess(addItemToOrder);
  1095. orderId = addItemToOrder.id;
  1096. // Assert that separate order lines have been created
  1097. expect(addItemToOrder.lines.length).toBe(2);
  1098. await shopClient.query<
  1099. CodegenShop.SetShippingAddressMutation,
  1100. CodegenShop.SetShippingAddressMutationVariables
  1101. >(SET_SHIPPING_ADDRESS, {
  1102. input: {
  1103. streetLine1: '1 Test Street',
  1104. countryCode: 'GB',
  1105. } as CreateAddressInput,
  1106. });
  1107. await setFirstEligibleShippingMethod();
  1108. await shopClient.query<
  1109. CodegenShop.TransitionToStateMutation,
  1110. CodegenShop.TransitionToStateMutationVariables
  1111. >(TRANSITION_TO_STATE, {
  1112. state: 'ArrangingPayment',
  1113. });
  1114. const { addPaymentToOrder: order } = await shopClient.query<
  1115. CodegenShop.AddPaymentToOrderMutation,
  1116. CodegenShop.AddPaymentToOrderMutationVariables
  1117. >(ADD_PAYMENT, {
  1118. input: {
  1119. method: testSuccessfulPaymentMethod.code,
  1120. metadata: {},
  1121. } as PaymentInput,
  1122. });
  1123. orderGuard.assertSuccess(order);
  1124. const product2 = await getProductWithStockMovement('T_2');
  1125. const [variant1_2, variant2_2, variant3_2] = product2!.variants;
  1126. expect(variant2_2.stockAllocated).toBe(2);
  1127. });
  1128. it('correctly creates Sales', async () => {
  1129. const product = await getProductWithStockMovement('T_2');
  1130. const [variant1, variant2, variant3] = product!.variants;
  1131. expect(variant2.stockOnHand).toBe(3);
  1132. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  1133. GET_ORDER,
  1134. {
  1135. id: orderId,
  1136. },
  1137. );
  1138. await adminClient.query<
  1139. Codegen.CreateFulfillmentMutation,
  1140. Codegen.CreateFulfillmentMutationVariables
  1141. >(CREATE_FULFILLMENT, {
  1142. input: {
  1143. lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
  1144. handler: {
  1145. code: manualFulfillmentHandler.code,
  1146. arguments: [
  1147. { name: 'method', value: 'test method' },
  1148. { name: 'trackingCode', value: 'ABC123' },
  1149. ],
  1150. },
  1151. },
  1152. });
  1153. const product2 = await getProductWithStockMovement('T_2');
  1154. const [variant1_2, variant2_2, variant3_2] = product2!.variants;
  1155. expect(variant2_2.stockAllocated).toBe(0);
  1156. expect(variant2_2.stockOnHand).toBe(1);
  1157. });
  1158. });
  1159. });
  1160. const UPDATE_STOCK_ON_HAND = gql`
  1161. mutation UpdateStock($input: [UpdateProductVariantInput!]!) {
  1162. updateProductVariants(input: $input) {
  1163. ...VariantWithStock
  1164. }
  1165. }
  1166. ${VARIANT_WITH_STOCK_FRAGMENT}
  1167. `;
  1168. export const TRANSITION_FULFILLMENT_TO_STATE = gql`
  1169. mutation TransitionFulfillmentToState($id: ID!, $state: String!) {
  1170. transitionFulfillmentToState(id: $id, state: $state) {
  1171. ... on Fulfillment {
  1172. id
  1173. state
  1174. nextStates
  1175. createdAt
  1176. }
  1177. ... on ErrorResult {
  1178. errorCode
  1179. message
  1180. }
  1181. ... on FulfillmentStateTransitionError {
  1182. transitionError
  1183. }
  1184. }
  1185. }
  1186. `;