stock-control-multi-location.e2e-spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { manualFulfillmentHandler, mergeConfig } from '@vendure/core';
  3. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  4. import path from 'path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  6. import { initialData } from '../../../e2e-common/e2e-initial-data';
  7. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  8. import { testSuccessfulPaymentMethod, twoStagePaymentMethod } from './fixtures/test-payment-methods';
  9. import { TestMultiLocationStockPlugin } from './fixtures/test-plugins/multi-location-stock-plugin';
  10. import { fulfillmentFragment } from './graphql/fragments-admin';
  11. import { graphql as graphqlAdmin } from './graphql/graphql-admin';
  12. import { FragmentOf, graphql } from './graphql/graphql-shop';
  13. import {
  14. cancelOrderDocument,
  15. createFulfillmentDocument,
  16. getOrderDocument,
  17. getStockMovementDocument,
  18. updateGlobalSettingsDocument,
  19. updateProductVariantsDocument,
  20. } from './graphql/shared-definitions';
  21. import {
  22. addPaymentDocument,
  23. getEligibleShippingMethodsDocument,
  24. getProductWithStockLevelDocument,
  25. setShippingAddressDocument,
  26. setShippingMethodDocument,
  27. testOrderFragment,
  28. transitionToStateDocument,
  29. updatedOrderFragment,
  30. } from './graphql/shop-definitions';
  31. describe('Stock control (multi-location)', () => {
  32. let defaultStockLocationId: string;
  33. let secondStockLocationId: string;
  34. const { server, adminClient, shopClient } = createTestEnvironment(
  35. mergeConfig(testConfig(), {
  36. paymentOptions: {
  37. paymentMethodHandlers: [testSuccessfulPaymentMethod],
  38. },
  39. plugins: [TestMultiLocationStockPlugin],
  40. }),
  41. );
  42. const orderGuard: ErrorResultGuard<
  43. FragmentOf<typeof testOrderFragment> | FragmentOf<typeof updatedOrderFragment>
  44. > = createErrorResultGuard(input => !!input.lines);
  45. const fulfillmentGuard: ErrorResultGuard<FragmentOf<typeof fulfillmentFragment>> = createErrorResultGuard(
  46. input => !!input.state,
  47. );
  48. async function getProductWithStockMovement(productId: string) {
  49. const { product } = await adminClient.query(getStockMovementDocument, { id: productId });
  50. return product;
  51. }
  52. async function setFirstEligibleShippingMethod() {
  53. const { eligibleShippingMethods } = await shopClient.query(getEligibleShippingMethodsDocument);
  54. await shopClient.query(setShippingMethodDocument, {
  55. id: [eligibleShippingMethods[0].id],
  56. });
  57. }
  58. beforeAll(async () => {
  59. await server.init({
  60. initialData: {
  61. ...initialData,
  62. paymentMethods: [
  63. {
  64. name: testSuccessfulPaymentMethod.code,
  65. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  66. },
  67. {
  68. name: twoStagePaymentMethod.code,
  69. handler: { code: twoStagePaymentMethod.code, arguments: [] },
  70. },
  71. ],
  72. },
  73. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control-multi.csv'),
  74. customerCount: 3,
  75. });
  76. await adminClient.asSuperAdmin();
  77. await adminClient.query(updateGlobalSettingsDocument, {
  78. input: {
  79. trackInventory: false,
  80. },
  81. });
  82. }, TEST_SETUP_TIMEOUT_MS);
  83. afterAll(async () => {
  84. await server.destroy();
  85. });
  86. it('default StockLocation exists', async () => {
  87. const { stockLocations } = await adminClient.query(GET_STOCK_LOCATIONS);
  88. expect(stockLocations.items.length).toBe(1);
  89. expect(stockLocations.items[0].name).toBe('Default Stock Location');
  90. defaultStockLocationId = stockLocations.items[0].id;
  91. });
  92. it('variant stock is all at default StockLocation', async () => {
  93. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {});
  94. expect(productVariants.items.every(variant => variant.stockLevels.length === 1)).toBe(true);
  95. expect(
  96. productVariants.items.every(
  97. variant => variant.stockLevels[0].stockLocationId === defaultStockLocationId,
  98. ),
  99. ).toBe(true);
  100. });
  101. it('create StockLocation', async () => {
  102. const { createStockLocation } = await adminClient.query(CREATE_STOCK_LOCATION, {
  103. input: {
  104. name: 'StockLocation1',
  105. description: 'StockLocation1',
  106. },
  107. });
  108. expect(createStockLocation).toEqual({
  109. id: 'T_2',
  110. name: 'StockLocation1',
  111. description: 'StockLocation1',
  112. });
  113. secondStockLocationId = createStockLocation.id;
  114. });
  115. it('update StockLocation', async () => {
  116. const { updateStockLocation } = await adminClient.query(UPDATE_STOCK_LOCATION, {
  117. input: {
  118. id: 'T_2',
  119. name: 'Warehouse 2',
  120. description: 'The secondary warehouse',
  121. },
  122. });
  123. expect(updateStockLocation).toEqual({
  124. id: 'T_2',
  125. name: 'Warehouse 2',
  126. description: 'The secondary warehouse',
  127. });
  128. });
  129. it('update ProductVariants with stock levels in second location', async () => {
  130. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {});
  131. const { updateProductVariants } = await adminClient.query(updateProductVariantsDocument, {
  132. input: productVariants.items.map(variant => ({
  133. id: variant.id,
  134. stockLevels: [{ stockLocationId: secondStockLocationId, stockOnHand: 120 }],
  135. })),
  136. });
  137. const {
  138. productVariants: { items },
  139. } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {});
  140. expect(items.every(variant => variant.stockLevels.length === 2)).toBe(true);
  141. expect(
  142. items.every(variant => {
  143. return (
  144. variant.stockLevels[0].stockLocationId === defaultStockLocationId &&
  145. variant.stockLevels[1].stockLocationId === secondStockLocationId
  146. );
  147. }),
  148. ).toBe(true);
  149. });
  150. it('StockLocationStrategy.getAvailableStock() is used to calculate saleable stock level', async () => {
  151. const result1 = await shopClient.query(getProductWithStockLevelDocument, {
  152. id: 'T_1',
  153. });
  154. expect(result1.product?.variants[0].stockLevel).toBe('220');
  155. const result2 = await shopClient.query(
  156. getProductWithStockLevelDocument,
  157. {
  158. id: 'T_1',
  159. },
  160. { fromLocation: 1 },
  161. );
  162. expect(result2.product?.variants[0].stockLevel).toBe('100');
  163. const result3 = await shopClient.query(
  164. getProductWithStockLevelDocument,
  165. {
  166. id: 'T_1',
  167. },
  168. { fromLocation: 2 },
  169. );
  170. expect(result3.product?.variants[0].stockLevel).toBe('120');
  171. });
  172. describe('stock movements', () => {
  173. const addItemToOrderWithCustomFieldsDocument = graphql(`
  174. mutation AddItemToOrderWithCustomFields(
  175. $productVariantId: ID!
  176. $quantity: Int!
  177. $customFields: OrderLineCustomFieldsInput
  178. ) {
  179. addItemToOrder(
  180. productVariantId: $productVariantId
  181. quantity: $quantity
  182. customFields: $customFields
  183. ) {
  184. ... on Order {
  185. id
  186. lines {
  187. id
  188. }
  189. }
  190. ... on ErrorResult {
  191. errorCode
  192. message
  193. }
  194. }
  195. }
  196. `);
  197. let orderId: string;
  198. it('creates Allocations according to StockLocationStrategy', async () => {
  199. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  200. await shopClient.query(addItemToOrderWithCustomFieldsDocument, {
  201. productVariantId: 'T_1',
  202. quantity: 2,
  203. customFields: {
  204. stockLocationId: '1',
  205. } as any,
  206. });
  207. const { addItemToOrder } = await shopClient.query(addItemToOrderWithCustomFieldsDocument, {
  208. productVariantId: 'T_2',
  209. quantity: 2,
  210. customFields: {
  211. stockLocationId: '2',
  212. } as any,
  213. });
  214. orderGuard.assertSuccess(addItemToOrder);
  215. orderId = addItemToOrder.id;
  216. expect(addItemToOrder.lines.length).toBe(2);
  217. // Do all steps to check out
  218. await shopClient.query(setShippingAddressDocument, {
  219. input: {
  220. streetLine1: '1 Test Street',
  221. countryCode: 'GB',
  222. },
  223. });
  224. const { eligibleShippingMethods } = await shopClient.query(getEligibleShippingMethodsDocument);
  225. await shopClient.query(setShippingMethodDocument, {
  226. id: [eligibleShippingMethods[0].id],
  227. });
  228. await shopClient.query(transitionToStateDocument, {
  229. state: 'ArrangingPayment',
  230. });
  231. const { addPaymentToOrder: order } = await shopClient.query(addPaymentDocument, {
  232. input: {
  233. method: testSuccessfulPaymentMethod.code,
  234. metadata: {},
  235. },
  236. });
  237. orderGuard.assertSuccess(order);
  238. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {
  239. options: {
  240. filter: {
  241. id: { in: ['T_1', 'T_2'] },
  242. },
  243. },
  244. });
  245. // First variant gets stock allocated from location 1
  246. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  247. {
  248. stockLocationId: 'T_1',
  249. stockOnHand: 100,
  250. stockAllocated: 2,
  251. },
  252. {
  253. stockLocationId: 'T_2',
  254. stockOnHand: 120,
  255. stockAllocated: 0,
  256. },
  257. ]);
  258. // Second variant gets stock allocated from location 2
  259. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  260. {
  261. stockLocationId: 'T_1',
  262. stockOnHand: 100,
  263. stockAllocated: 0,
  264. },
  265. {
  266. stockLocationId: 'T_2',
  267. stockOnHand: 120,
  268. stockAllocated: 2,
  269. },
  270. ]);
  271. });
  272. it('creates Releases according to StockLocationStrategy', async () => {
  273. const { order } = await adminClient.query(getOrderDocument, { id: orderId });
  274. const { cancelOrder } = await adminClient.query(cancelOrderDocument, {
  275. input: {
  276. orderId,
  277. lines: order?.lines
  278. .filter(l => l.productVariant.id === 'T_2')
  279. .map(l => ({
  280. orderLineId: l.id,
  281. quantity: 1,
  282. })),
  283. },
  284. });
  285. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {
  286. options: {
  287. filter: {
  288. id: { eq: 'T_2' },
  289. },
  290. },
  291. });
  292. // Second variant gets stock allocated from location 2
  293. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  294. {
  295. stockLocationId: 'T_1',
  296. stockOnHand: 100,
  297. stockAllocated: 0,
  298. },
  299. {
  300. stockLocationId: 'T_2',
  301. stockOnHand: 120,
  302. stockAllocated: 1,
  303. },
  304. ]);
  305. });
  306. it('creates Sales according to StockLocationStrategy', async () => {
  307. const { order } = await adminClient.query(getOrderDocument, { id: orderId });
  308. await adminClient.query(createFulfillmentDocument, {
  309. input: {
  310. handler: {
  311. code: manualFulfillmentHandler.code,
  312. arguments: [{ name: 'method', value: 'Test1' }],
  313. },
  314. lines: order!.lines.map(l => ({
  315. orderLineId: l.id,
  316. quantity: l.quantity,
  317. })),
  318. },
  319. });
  320. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {
  321. options: {
  322. filter: {
  323. id: { in: ['T_1', 'T_2'] },
  324. },
  325. },
  326. });
  327. // Second variant gets stock allocated from location 2
  328. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  329. {
  330. stockLocationId: 'T_1',
  331. stockOnHand: 98,
  332. stockAllocated: 0,
  333. },
  334. {
  335. stockLocationId: 'T_2',
  336. stockOnHand: 120,
  337. stockAllocated: 0,
  338. },
  339. ]);
  340. // Second variant gets stock allocated from location 2
  341. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  342. {
  343. stockLocationId: 'T_1',
  344. stockOnHand: 100,
  345. stockAllocated: 0,
  346. },
  347. {
  348. stockLocationId: 'T_2',
  349. stockOnHand: 119,
  350. stockAllocated: 0,
  351. },
  352. ]);
  353. });
  354. it('creates Cancellations according to StockLocationStrategy', async () => {
  355. const { order } = await adminClient.query(getOrderDocument, { id: orderId });
  356. await adminClient.query(cancelOrderDocument, {
  357. input: {
  358. orderId,
  359. cancelShipping: true,
  360. reason: 'No longer needed',
  361. },
  362. });
  363. const { productVariants } = await adminClient.query(GET_VARIANT_STOCK_LEVELS, {
  364. options: {
  365. filter: {
  366. id: { in: ['T_1', 'T_2'] },
  367. },
  368. },
  369. });
  370. // Second variant gets stock allocated from location 2
  371. expect(productVariants.items.find(v => v.id === 'T_1')?.stockLevels).toEqual([
  372. {
  373. stockLocationId: 'T_1',
  374. stockOnHand: 100,
  375. stockAllocated: 0,
  376. },
  377. {
  378. stockLocationId: 'T_2',
  379. stockOnHand: 120,
  380. stockAllocated: 0,
  381. },
  382. ]);
  383. // Second variant gets stock allocated from location 2
  384. expect(productVariants.items.find(v => v.id === 'T_2')?.stockLevels).toEqual([
  385. {
  386. stockLocationId: 'T_1',
  387. stockOnHand: 100,
  388. stockAllocated: 0,
  389. },
  390. {
  391. stockLocationId: 'T_2',
  392. stockOnHand: 120,
  393. stockAllocated: 0,
  394. },
  395. ]);
  396. });
  397. });
  398. });
  399. const stockLocationFragment = graphqlAdmin(`
  400. fragment StockLocation on StockLocation {
  401. id
  402. name
  403. description
  404. }
  405. `);
  406. const GET_STOCK_LOCATION = graphqlAdmin(
  407. `
  408. query GetStockLocation($id: ID!) {
  409. stockLocation(id: $id) {
  410. ...StockLocation
  411. }
  412. }
  413. `,
  414. [stockLocationFragment],
  415. );
  416. const GET_STOCK_LOCATIONS = graphqlAdmin(
  417. `
  418. query GetStockLocations($options: StockLocationListOptions) {
  419. stockLocations(options: $options) {
  420. items {
  421. ...StockLocation
  422. }
  423. totalItems
  424. }
  425. }
  426. `,
  427. [stockLocationFragment],
  428. );
  429. const CREATE_STOCK_LOCATION = graphqlAdmin(
  430. `
  431. mutation CreateStockLocation($input: CreateStockLocationInput!) {
  432. createStockLocation(input: $input) {
  433. ...StockLocation
  434. }
  435. }
  436. `,
  437. [stockLocationFragment],
  438. );
  439. const UPDATE_STOCK_LOCATION = graphqlAdmin(
  440. `
  441. mutation UpdateStockLocation($input: UpdateStockLocationInput!) {
  442. updateStockLocation(input: $input) {
  443. ...StockLocation
  444. }
  445. }
  446. `,
  447. [stockLocationFragment],
  448. );
  449. const GET_VARIANT_STOCK_LEVELS = graphqlAdmin(`
  450. query GetVariantStockLevels($options: ProductVariantListOptions) {
  451. productVariants(options: $options) {
  452. items {
  453. id
  454. name
  455. stockOnHand
  456. stockAllocated
  457. stockLevels {
  458. stockLocationId
  459. stockOnHand
  460. stockAllocated
  461. }
  462. }
  463. }
  464. }
  465. `);