product.e2e-spec.ts 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948
  1. import { omit } from '@vendure/common/lib/omit';
  2. import { pick } from '@vendure/common/lib/pick';
  3. import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
  4. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  5. import gql from 'graphql-tag';
  6. import path from 'path';
  7. import { initialData } from '../../../e2e-common/e2e-initial-data';
  8. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  9. import { PRODUCT_VARIANT_FRAGMENT, PRODUCT_WITH_OPTIONS_FRAGMENT } from './graphql/fragments';
  10. import * as Codegen from './graphql/generated-e2e-admin-types';
  11. import { DeletionResult, ErrorCode, LanguageCode, SortOrder } from './graphql/generated-e2e-admin-types';
  12. import {
  13. ADD_OPTION_GROUP_TO_PRODUCT,
  14. CREATE_PRODUCT,
  15. CREATE_PRODUCT_VARIANTS,
  16. DELETE_PRODUCT,
  17. DELETE_PRODUCT_VARIANT,
  18. GET_ASSET_LIST,
  19. GET_PRODUCT_LIST,
  20. GET_PRODUCT_SIMPLE,
  21. GET_PRODUCT_WITH_VARIANTS,
  22. UPDATE_PRODUCT,
  23. UPDATE_PRODUCT_VARIANTS,
  24. } from './graphql/shared-definitions';
  25. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  26. // tslint:disable:no-non-null-assertion
  27. describe('Product resolver', () => {
  28. const { server, adminClient, shopClient } = createTestEnvironment(testConfig());
  29. const removeOptionGuard: ErrorResultGuard<Codegen.ProductWithOptionsFragment> = createErrorResultGuard(
  30. input => !!input.optionGroups,
  31. );
  32. beforeAll(async () => {
  33. await server.init({
  34. initialData,
  35. customerCount: 1,
  36. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  37. });
  38. await adminClient.asSuperAdmin();
  39. }, TEST_SETUP_TIMEOUT_MS);
  40. afterAll(async () => {
  41. await server.destroy();
  42. });
  43. describe('products list query', () => {
  44. it('returns all products when no options passed', async () => {
  45. const result = await adminClient.query<
  46. Codegen.GetProductListQuery,
  47. Codegen.GetProductListQueryVariables
  48. >(GET_PRODUCT_LIST, {});
  49. expect(result.products.items.length).toBe(20);
  50. expect(result.products.totalItems).toBe(20);
  51. });
  52. it('limits result set with skip & take', async () => {
  53. const result = await adminClient.query<
  54. Codegen.GetProductListQuery,
  55. Codegen.GetProductListQueryVariables
  56. >(GET_PRODUCT_LIST, {
  57. options: {
  58. skip: 0,
  59. take: 3,
  60. },
  61. });
  62. expect(result.products.items.length).toBe(3);
  63. expect(result.products.totalItems).toBe(20);
  64. });
  65. it('filters by name admin', async () => {
  66. const result = await adminClient.query<
  67. Codegen.GetProductListQuery,
  68. Codegen.GetProductListQueryVariables
  69. >(GET_PRODUCT_LIST, {
  70. options: {
  71. filter: {
  72. name: {
  73. contains: 'skateboard',
  74. },
  75. },
  76. },
  77. });
  78. expect(result.products.items.length).toBe(1);
  79. expect(result.products.items[0].name).toBe('Cruiser Skateboard');
  80. });
  81. it('filters multiple admin', async () => {
  82. const result = await adminClient.query<
  83. Codegen.GetProductListQuery,
  84. Codegen.GetProductListQueryVariables
  85. >(GET_PRODUCT_LIST, {
  86. options: {
  87. filter: {
  88. name: {
  89. contains: 'camera',
  90. },
  91. slug: {
  92. contains: 'tent',
  93. },
  94. },
  95. },
  96. });
  97. expect(result.products.items.length).toBe(0);
  98. });
  99. it('sorts by name admin', async () => {
  100. const result = await adminClient.query<
  101. Codegen.GetProductListQuery,
  102. Codegen.GetProductListQueryVariables
  103. >(GET_PRODUCT_LIST, {
  104. options: {
  105. sort: {
  106. name: SortOrder.ASC,
  107. },
  108. },
  109. });
  110. expect(result.products.items.map(p => p.name)).toEqual([
  111. 'Bonsai Tree',
  112. 'Boxing Gloves',
  113. 'Camera Lens',
  114. 'Clacky Keyboard',
  115. 'Cruiser Skateboard',
  116. 'Curvy Monitor',
  117. 'Football',
  118. 'Gaming PC',
  119. 'Hard Drive',
  120. 'Instant Camera',
  121. 'Laptop',
  122. 'Orchid',
  123. 'Road Bike',
  124. 'Running Shoe',
  125. 'Skipping Rope',
  126. 'Slr Camera',
  127. 'Spiky Cactus',
  128. 'Tent',
  129. 'Tripod',
  130. 'USB Cable',
  131. ]);
  132. });
  133. it('filters by name shop', async () => {
  134. const result = await shopClient.query<
  135. Codegen.GetProductListQuery,
  136. Codegen.GetProductListQueryVariables
  137. >(GET_PRODUCT_LIST, {
  138. options: {
  139. filter: {
  140. name: {
  141. contains: 'skateboard',
  142. },
  143. },
  144. },
  145. });
  146. expect(result.products.items.length).toBe(1);
  147. expect(result.products.items[0].name).toBe('Cruiser Skateboard');
  148. });
  149. it('sorts by name shop', async () => {
  150. const result = await shopClient.query<
  151. Codegen.GetProductListQuery,
  152. Codegen.GetProductListQueryVariables
  153. >(GET_PRODUCT_LIST, {
  154. options: {
  155. sort: {
  156. name: SortOrder.ASC,
  157. },
  158. },
  159. });
  160. expect(result.products.items.map(p => p.name)).toEqual([
  161. 'Bonsai Tree',
  162. 'Boxing Gloves',
  163. 'Camera Lens',
  164. 'Clacky Keyboard',
  165. 'Cruiser Skateboard',
  166. 'Curvy Monitor',
  167. 'Football',
  168. 'Gaming PC',
  169. 'Hard Drive',
  170. 'Instant Camera',
  171. 'Laptop',
  172. 'Orchid',
  173. 'Road Bike',
  174. 'Running Shoe',
  175. 'Skipping Rope',
  176. 'Slr Camera',
  177. 'Spiky Cactus',
  178. 'Tent',
  179. 'Tripod',
  180. 'USB Cable',
  181. ]);
  182. });
  183. });
  184. describe('product query', () => {
  185. it('by id', async () => {
  186. const { product } = await adminClient.query<
  187. Codegen.GetProductSimpleQuery,
  188. Codegen.GetProductSimpleQueryVariables
  189. >(GET_PRODUCT_SIMPLE, { id: 'T_2' });
  190. if (!product) {
  191. fail('Product not found');
  192. return;
  193. }
  194. expect(product.id).toBe('T_2');
  195. });
  196. it('by slug', async () => {
  197. const { product } = await adminClient.query<
  198. Codegen.GetProductSimpleQuery,
  199. Codegen.GetProductSimpleQueryVariables
  200. >(GET_PRODUCT_SIMPLE, { slug: 'curvy-monitor' });
  201. if (!product) {
  202. fail('Product not found');
  203. return;
  204. }
  205. expect(product.slug).toBe('curvy-monitor');
  206. });
  207. // https://github.com/vendure-ecommerce/vendure/issues/820
  208. it('by slug with multiple assets', async () => {
  209. const { product: product1 } = await adminClient.query<
  210. Codegen.GetProductSimpleQuery,
  211. Codegen.GetProductSimpleQueryVariables
  212. >(GET_PRODUCT_SIMPLE, { id: 'T_1' });
  213. const result = await adminClient.query<
  214. Codegen.UpdateProductMutation,
  215. Codegen.UpdateProductMutationVariables
  216. >(UPDATE_PRODUCT, {
  217. input: {
  218. id: product1!.id,
  219. assetIds: ['T_1', 'T_2', 'T_3'],
  220. },
  221. });
  222. const { product } = await adminClient.query<
  223. Codegen.GetProductWithVariantsQuery,
  224. Codegen.GetProductWithVariantsQueryVariables
  225. >(GET_PRODUCT_WITH_VARIANTS, { slug: product1!.slug });
  226. if (!product) {
  227. fail('Product not found');
  228. return;
  229. }
  230. expect(product.assets.map(a => a.id)).toEqual(['T_1', 'T_2', 'T_3']);
  231. });
  232. // https://github.com/vendure-ecommerce/vendure/issues/538
  233. it('falls back to default language slug', async () => {
  234. const { product } = await adminClient.query<
  235. Codegen.GetProductSimpleQuery,
  236. Codegen.GetProductSimpleQueryVariables
  237. >(GET_PRODUCT_SIMPLE, { slug: 'curvy-monitor' }, { languageCode: LanguageCode.de });
  238. if (!product) {
  239. fail('Product not found');
  240. return;
  241. }
  242. expect(product.slug).toBe('curvy-monitor');
  243. });
  244. it(
  245. 'throws if neither id nor slug provided',
  246. assertThrowsWithMessage(async () => {
  247. await adminClient.query<
  248. Codegen.GetProductSimpleQuery,
  249. Codegen.GetProductSimpleQueryVariables
  250. >(GET_PRODUCT_SIMPLE, {});
  251. }, 'Either the Product id or slug must be provided'),
  252. );
  253. it(
  254. 'throws if id and slug do not refer to the same Product',
  255. assertThrowsWithMessage(async () => {
  256. await adminClient.query<
  257. Codegen.GetProductSimpleQuery,
  258. Codegen.GetProductSimpleQueryVariables
  259. >(GET_PRODUCT_SIMPLE, {
  260. id: 'T_2',
  261. slug: 'laptop',
  262. });
  263. }, 'The provided id and slug refer to different Products'),
  264. );
  265. it('returns expected properties', async () => {
  266. const { product } = await adminClient.query<
  267. Codegen.GetProductWithVariantsQuery,
  268. Codegen.GetProductWithVariantsQueryVariables
  269. >(GET_PRODUCT_WITH_VARIANTS, {
  270. id: 'T_2',
  271. });
  272. if (!product) {
  273. fail('Product not found');
  274. return;
  275. }
  276. expect(omit(product, ['variants'])).toMatchSnapshot();
  277. expect(product.variants.length).toBe(2);
  278. });
  279. it('ProductVariant price properties are correct', async () => {
  280. const result = await adminClient.query<
  281. Codegen.GetProductWithVariantsQuery,
  282. Codegen.GetProductWithVariantsQueryVariables
  283. >(GET_PRODUCT_WITH_VARIANTS, {
  284. id: 'T_2',
  285. });
  286. if (!result.product) {
  287. fail('Product not found');
  288. return;
  289. }
  290. expect(result.product.variants[0].price).toBe(14374);
  291. expect(result.product.variants[0].taxCategory).toEqual({
  292. id: 'T_1',
  293. name: 'Standard Tax',
  294. });
  295. });
  296. it('returns null when id not found', async () => {
  297. const result = await adminClient.query<
  298. Codegen.GetProductWithVariantsQuery,
  299. Codegen.GetProductWithVariantsQueryVariables
  300. >(GET_PRODUCT_WITH_VARIANTS, {
  301. id: 'bad_id',
  302. });
  303. expect(result.product).toBeNull();
  304. });
  305. it('returns null when slug not found', async () => {
  306. const result = await adminClient.query<
  307. Codegen.GetProductWithVariantsQuery,
  308. Codegen.GetProductWithVariantsQueryVariables
  309. >(GET_PRODUCT_WITH_VARIANTS, {
  310. slug: 'bad_slug',
  311. });
  312. expect(result.product).toBeNull();
  313. });
  314. describe('product query with translations', () => {
  315. let translatedProduct: Codegen.ProductWithVariantsFragment;
  316. let en_translation: Codegen.ProductWithVariantsFragment['translations'][number];
  317. let de_translation: Codegen.ProductWithVariantsFragment['translations'][number];
  318. beforeAll(async () => {
  319. const result = await adminClient.query<
  320. Codegen.CreateProductMutation,
  321. Codegen.CreateProductMutationVariables
  322. >(CREATE_PRODUCT, {
  323. input: {
  324. translations: [
  325. {
  326. languageCode: LanguageCode.en,
  327. name: 'en Pineapple',
  328. slug: 'en-pineapple',
  329. description: 'A delicious pineapple',
  330. },
  331. {
  332. languageCode: LanguageCode.de,
  333. name: 'de Ananas',
  334. slug: 'de-ananas',
  335. description: 'Eine köstliche Ananas',
  336. },
  337. ],
  338. },
  339. });
  340. translatedProduct = result.createProduct;
  341. en_translation = translatedProduct.translations.find(
  342. t => t.languageCode === LanguageCode.en,
  343. )!;
  344. de_translation = translatedProduct.translations.find(
  345. t => t.languageCode === LanguageCode.de,
  346. )!;
  347. });
  348. it('en slug without translation arg', async () => {
  349. const { product } = await adminClient.query<
  350. Codegen.GetProductSimpleQuery,
  351. Codegen.GetProductSimpleQueryVariables
  352. >(GET_PRODUCT_SIMPLE, { slug: en_translation.slug });
  353. if (!product) {
  354. fail('Product not found');
  355. return;
  356. }
  357. expect(product.slug).toBe(en_translation.slug);
  358. });
  359. it('de slug without translation arg', async () => {
  360. const { product } = await adminClient.query<
  361. Codegen.GetProductSimpleQuery,
  362. Codegen.GetProductSimpleQueryVariables
  363. >(GET_PRODUCT_SIMPLE, { slug: de_translation.slug });
  364. if (!product) {
  365. fail('Product not found');
  366. return;
  367. }
  368. expect(product.slug).toBe(en_translation.slug);
  369. });
  370. it('en slug with translation en', async () => {
  371. const { product } = await adminClient.query<
  372. Codegen.GetProductSimpleQuery,
  373. Codegen.GetProductSimpleQueryVariables
  374. >(GET_PRODUCT_SIMPLE, { slug: en_translation.slug }, { languageCode: LanguageCode.en });
  375. if (!product) {
  376. fail('Product not found');
  377. return;
  378. }
  379. expect(product.slug).toBe(en_translation.slug);
  380. });
  381. it('de slug with translation en', async () => {
  382. const { product } = await adminClient.query<
  383. Codegen.GetProductSimpleQuery,
  384. Codegen.GetProductSimpleQueryVariables
  385. >(GET_PRODUCT_SIMPLE, { slug: de_translation.slug }, { languageCode: LanguageCode.en });
  386. if (!product) {
  387. fail('Product not found');
  388. return;
  389. }
  390. expect(product.slug).toBe(en_translation.slug);
  391. });
  392. it('en slug with translation de', async () => {
  393. const { product } = await adminClient.query<
  394. Codegen.GetProductSimpleQuery,
  395. Codegen.GetProductSimpleQueryVariables
  396. >(GET_PRODUCT_SIMPLE, { slug: en_translation.slug }, { languageCode: LanguageCode.de });
  397. if (!product) {
  398. fail('Product not found');
  399. return;
  400. }
  401. expect(product.slug).toBe(de_translation.slug);
  402. });
  403. it('de slug with translation de', async () => {
  404. const { product } = await adminClient.query<
  405. Codegen.GetProductSimpleQuery,
  406. Codegen.GetProductSimpleQueryVariables
  407. >(GET_PRODUCT_SIMPLE, { slug: de_translation.slug }, { languageCode: LanguageCode.de });
  408. if (!product) {
  409. fail('Product not found');
  410. return;
  411. }
  412. expect(product.slug).toBe(de_translation.slug);
  413. });
  414. it('de slug with translation ru', async () => {
  415. const { product } = await adminClient.query<
  416. Codegen.GetProductSimpleQuery,
  417. Codegen.GetProductSimpleQueryVariables
  418. >(GET_PRODUCT_SIMPLE, { slug: de_translation.slug }, { languageCode: LanguageCode.ru });
  419. if (!product) {
  420. fail('Product not found');
  421. return;
  422. }
  423. expect(product.slug).toBe(en_translation.slug);
  424. });
  425. });
  426. describe('product.variants', () => {
  427. it('returns product variants', async () => {
  428. const { product } = await adminClient.query<
  429. Codegen.GetProductWithVariantsQuery,
  430. Codegen.GetProductWithVariantsQueryVariables
  431. >(GET_PRODUCT_WITH_VARIANTS, {
  432. id: 'T_1',
  433. });
  434. expect(product?.variants.length).toBe(4);
  435. });
  436. it('returns product variants in existing language', async () => {
  437. const { product } = await adminClient.query<
  438. Codegen.GetProductWithVariantsQuery,
  439. Codegen.GetProductWithVariantsQueryVariables
  440. >(
  441. GET_PRODUCT_WITH_VARIANTS,
  442. {
  443. id: 'T_1',
  444. },
  445. { languageCode: LanguageCode.en },
  446. );
  447. expect(product?.variants.length).toBe(4);
  448. });
  449. it('returns product variants in non-existing language', async () => {
  450. const { product } = await adminClient.query<
  451. Codegen.GetProductWithVariantsQuery,
  452. Codegen.GetProductWithVariantsQueryVariables
  453. >(
  454. GET_PRODUCT_WITH_VARIANTS,
  455. {
  456. id: 'T_1',
  457. },
  458. { languageCode: LanguageCode.ru },
  459. );
  460. expect(product?.variants.length).toBe(4);
  461. });
  462. });
  463. describe('product.variants', () => {
  464. it('returns product variants', async () => {
  465. const { product } = await adminClient.query<
  466. Codegen.GetProductWithVariantsQuery,
  467. Codegen.GetProductWithVariantsQueryVariables
  468. >(GET_PRODUCT_WITH_VARIANTS, {
  469. id: 'T_1',
  470. });
  471. expect(product?.variants.length).toBe(4);
  472. });
  473. it('returns product variants in existing language', async () => {
  474. const { product } = await adminClient.query<
  475. Codegen.GetProductWithVariantsQuery,
  476. Codegen.GetProductWithVariantsQueryVariables
  477. >(
  478. GET_PRODUCT_WITH_VARIANTS,
  479. {
  480. id: 'T_1',
  481. },
  482. { languageCode: LanguageCode.en },
  483. );
  484. expect(product?.variants.length).toBe(4);
  485. });
  486. it('returns product variants in non-existing language', async () => {
  487. const { product } = await adminClient.query<
  488. Codegen.GetProductWithVariantsQuery,
  489. Codegen.GetProductWithVariantsQueryVariables
  490. >(
  491. GET_PRODUCT_WITH_VARIANTS,
  492. {
  493. id: 'T_1',
  494. },
  495. { languageCode: LanguageCode.ru },
  496. );
  497. expect(product?.variants.length).toBe(4);
  498. });
  499. });
  500. describe('product.variantList', () => {
  501. it('returns product variants', async () => {
  502. const { product } = await adminClient.query<
  503. Codegen.GetProductWithVariantListQuery,
  504. Codegen.GetProductWithVariantListQueryVariables
  505. >(GET_PRODUCT_WITH_VARIANT_LIST, {
  506. id: 'T_1',
  507. });
  508. expect(product?.variantList.items.length).toBe(4);
  509. expect(product?.variantList.totalItems).toBe(4);
  510. });
  511. it('returns product variants in existing language', async () => {
  512. const { product } = await adminClient.query<
  513. Codegen.GetProductWithVariantListQuery,
  514. Codegen.GetProductWithVariantListQueryVariables
  515. >(
  516. GET_PRODUCT_WITH_VARIANT_LIST,
  517. {
  518. id: 'T_1',
  519. },
  520. { languageCode: LanguageCode.en },
  521. );
  522. expect(product?.variantList.items.length).toBe(4);
  523. });
  524. it('returns product variants in non-existing language', async () => {
  525. const { product } = await adminClient.query<
  526. Codegen.GetProductWithVariantListQuery,
  527. Codegen.GetProductWithVariantListQueryVariables
  528. >(
  529. GET_PRODUCT_WITH_VARIANT_LIST,
  530. {
  531. id: 'T_1',
  532. },
  533. { languageCode: LanguageCode.ru },
  534. );
  535. expect(product?.variantList.items.length).toBe(4);
  536. });
  537. it('filter & sort', async () => {
  538. const { product } = await adminClient.query<
  539. Codegen.GetProductWithVariantListQuery,
  540. Codegen.GetProductWithVariantListQueryVariables
  541. >(GET_PRODUCT_WITH_VARIANT_LIST, {
  542. id: 'T_1',
  543. variantListOptions: {
  544. filter: {
  545. name: {
  546. contains: '15',
  547. },
  548. },
  549. sort: {
  550. price: SortOrder.DESC,
  551. },
  552. },
  553. });
  554. expect(product?.variantList.items.map(i => i.name)).toEqual([
  555. 'Laptop 15 inch 16GB',
  556. 'Laptop 15 inch 8GB',
  557. ]);
  558. });
  559. });
  560. });
  561. describe('productVariants list query', () => {
  562. it('returns list', async () => {
  563. const { productVariants } = await adminClient.query<
  564. Codegen.GetProductVariantListQuery,
  565. Codegen.GetProductVariantListQueryVariables
  566. >(GET_PRODUCT_VARIANT_LIST, {
  567. options: {
  568. take: 3,
  569. sort: {
  570. name: SortOrder.ASC,
  571. },
  572. },
  573. });
  574. expect(productVariants.items).toEqual([
  575. {
  576. id: 'T_34',
  577. name: 'Bonsai Tree',
  578. price: 1999,
  579. priceWithTax: 2399,
  580. sku: 'B01MXFLUSV',
  581. },
  582. {
  583. id: 'T_24',
  584. name: 'Boxing Gloves',
  585. price: 3304,
  586. priceWithTax: 3965,
  587. sku: 'B000ZYLPPU',
  588. },
  589. {
  590. id: 'T_19',
  591. name: 'Camera Lens',
  592. price: 10400,
  593. priceWithTax: 12480,
  594. sku: 'B0012UUP02',
  595. },
  596. ]);
  597. });
  598. it('sort by price', async () => {
  599. const { productVariants } = await adminClient.query<
  600. Codegen.GetProductVariantListQuery,
  601. Codegen.GetProductVariantListQueryVariables
  602. >(GET_PRODUCT_VARIANT_LIST, {
  603. options: {
  604. take: 3,
  605. sort: {
  606. price: SortOrder.ASC,
  607. },
  608. },
  609. });
  610. expect(productVariants.items).toEqual([
  611. {
  612. id: 'T_23',
  613. name: 'Skipping Rope',
  614. price: 799,
  615. priceWithTax: 959,
  616. sku: 'B07CNGXVXT',
  617. },
  618. {
  619. id: 'T_20',
  620. name: 'Tripod',
  621. price: 1498,
  622. priceWithTax: 1798,
  623. sku: 'B00XI87KV8',
  624. },
  625. {
  626. id: 'T_32',
  627. name: 'Spiky Cactus',
  628. price: 1550,
  629. priceWithTax: 1860,
  630. sku: 'SC011001',
  631. },
  632. ]);
  633. });
  634. it('sort by priceWithTax', async () => {
  635. const { productVariants } = await adminClient.query<
  636. Codegen.GetProductVariantListQuery,
  637. Codegen.GetProductVariantListQueryVariables
  638. >(GET_PRODUCT_VARIANT_LIST, {
  639. options: {
  640. take: 3,
  641. sort: {
  642. priceWithTax: SortOrder.ASC,
  643. },
  644. },
  645. });
  646. expect(productVariants.items).toEqual([
  647. {
  648. id: 'T_23',
  649. name: 'Skipping Rope',
  650. price: 799,
  651. priceWithTax: 959,
  652. sku: 'B07CNGXVXT',
  653. },
  654. {
  655. id: 'T_20',
  656. name: 'Tripod',
  657. price: 1498,
  658. priceWithTax: 1798,
  659. sku: 'B00XI87KV8',
  660. },
  661. {
  662. id: 'T_32',
  663. name: 'Spiky Cactus',
  664. price: 1550,
  665. priceWithTax: 1860,
  666. sku: 'SC011001',
  667. },
  668. ]);
  669. });
  670. it('filter by price', async () => {
  671. const { productVariants } = await adminClient.query<
  672. Codegen.GetProductVariantListQuery,
  673. Codegen.GetProductVariantListQueryVariables
  674. >(GET_PRODUCT_VARIANT_LIST, {
  675. options: {
  676. take: 3,
  677. filter: {
  678. price: {
  679. between: {
  680. start: 1400,
  681. end: 1500,
  682. },
  683. },
  684. },
  685. },
  686. });
  687. expect(productVariants.items).toEqual([
  688. {
  689. id: 'T_20',
  690. name: 'Tripod',
  691. price: 1498,
  692. priceWithTax: 1798,
  693. sku: 'B00XI87KV8',
  694. },
  695. ]);
  696. });
  697. it('filter by priceWithTax', async () => {
  698. const { productVariants } = await adminClient.query<
  699. Codegen.GetProductVariantListQuery,
  700. Codegen.GetProductVariantListQueryVariables
  701. >(GET_PRODUCT_VARIANT_LIST, {
  702. options: {
  703. take: 3,
  704. filter: {
  705. priceWithTax: {
  706. between: {
  707. start: 1400,
  708. end: 1500,
  709. },
  710. },
  711. },
  712. },
  713. });
  714. // Note the results are incorrect. This is a design trade-off. See the
  715. // commend on the ProductVariant.priceWithTax annotation for explanation.
  716. expect(productVariants.items).toEqual([
  717. {
  718. id: 'T_20',
  719. name: 'Tripod',
  720. price: 1498,
  721. priceWithTax: 1798,
  722. sku: 'B00XI87KV8',
  723. },
  724. ]);
  725. });
  726. it('returns variants for particular product by id', async () => {
  727. const { productVariants } = await adminClient.query<
  728. Codegen.GetProductVariantListQuery,
  729. Codegen.GetProductVariantListQueryVariables
  730. >(GET_PRODUCT_VARIANT_LIST, {
  731. options: {
  732. take: 3,
  733. sort: {
  734. price: SortOrder.ASC,
  735. },
  736. },
  737. productId: 'T_1',
  738. });
  739. expect(productVariants.items).toEqual([
  740. {
  741. id: 'T_1',
  742. name: 'Laptop 13 inch 8GB',
  743. price: 129900,
  744. priceWithTax: 155880,
  745. sku: 'L2201308',
  746. },
  747. {
  748. id: 'T_2',
  749. name: 'Laptop 15 inch 8GB',
  750. price: 139900,
  751. priceWithTax: 167880,
  752. sku: 'L2201508',
  753. },
  754. {
  755. id: 'T_3',
  756. name: 'Laptop 13 inch 16GB',
  757. priceWithTax: 263880,
  758. price: 219900,
  759. sku: 'L2201316',
  760. },
  761. ]);
  762. });
  763. });
  764. describe('productVariant query', () => {
  765. it('by id', async () => {
  766. const { productVariant } = await adminClient.query<
  767. Codegen.GetProductVariantQuery,
  768. Codegen.GetProductVariantQueryVariables
  769. >(GET_PRODUCT_VARIANT, {
  770. id: 'T_1',
  771. });
  772. expect(productVariant?.id).toBe('T_1');
  773. expect(productVariant?.name).toBe('Laptop 13 inch 8GB');
  774. });
  775. it('returns null when id not found', async () => {
  776. const { productVariant } = await adminClient.query<
  777. Codegen.GetProductVariantQuery,
  778. Codegen.GetProductVariantQueryVariables
  779. >(GET_PRODUCT_VARIANT, {
  780. id: 'T_999',
  781. });
  782. expect(productVariant).toBeNull();
  783. });
  784. });
  785. describe('product mutation', () => {
  786. let newTranslatedProduct: Codegen.ProductWithVariantsFragment;
  787. let newProduct: Codegen.ProductWithVariantsFragment;
  788. let newProductWithAssets: Codegen.ProductWithVariantsFragment;
  789. it('createProduct creates a new Product', async () => {
  790. const result = await adminClient.query<
  791. Codegen.CreateProductMutation,
  792. Codegen.CreateProductMutationVariables
  793. >(CREATE_PRODUCT, {
  794. input: {
  795. translations: [
  796. {
  797. languageCode: LanguageCode.en,
  798. name: 'en Baked Potato',
  799. slug: 'en Baked Potato',
  800. description: 'A baked potato',
  801. },
  802. {
  803. languageCode: LanguageCode.de,
  804. name: 'de Baked Potato',
  805. slug: 'de-baked-potato',
  806. description: 'Eine baked Erdapfel',
  807. },
  808. ],
  809. },
  810. });
  811. expect(omit(result.createProduct, ['translations'])).toMatchSnapshot();
  812. expect(result.createProduct.translations.map(t => t.description).sort()).toEqual([
  813. 'A baked potato',
  814. 'Eine baked Erdapfel',
  815. ]);
  816. newTranslatedProduct = result.createProduct;
  817. });
  818. it('createProduct creates a new Product with assets', async () => {
  819. const assetsResult = await adminClient.query<
  820. Codegen.GetAssetListQuery,
  821. Codegen.GetAssetListQueryVariables
  822. >(GET_ASSET_LIST);
  823. const assetIds = assetsResult.assets.items.slice(0, 2).map(a => a.id);
  824. const featuredAssetId = assetsResult.assets.items[0].id;
  825. const result = await adminClient.query<
  826. Codegen.CreateProductMutation,
  827. Codegen.CreateProductMutationVariables
  828. >(CREATE_PRODUCT, {
  829. input: {
  830. assetIds,
  831. featuredAssetId,
  832. translations: [
  833. {
  834. languageCode: LanguageCode.en,
  835. name: 'en Has Assets',
  836. slug: 'en-has-assets',
  837. description: 'A product with assets',
  838. },
  839. ],
  840. },
  841. });
  842. expect(result.createProduct.assets.map(a => a.id)).toEqual(assetIds);
  843. expect(result.createProduct.featuredAsset!.id).toBe(featuredAssetId);
  844. newProductWithAssets = result.createProduct;
  845. });
  846. it('createProduct creates a disabled Product', async () => {
  847. const result = await adminClient.query<
  848. Codegen.CreateProductMutation,
  849. Codegen.CreateProductMutationVariables
  850. >(CREATE_PRODUCT, {
  851. input: {
  852. enabled: false,
  853. translations: [
  854. {
  855. languageCode: LanguageCode.en,
  856. name: 'en Small apple',
  857. slug: 'en-small-apple',
  858. description: 'A small apple',
  859. },
  860. ],
  861. },
  862. });
  863. expect(result.createProduct.enabled).toBe(false);
  864. newProduct = result.createProduct;
  865. });
  866. it('updateProduct updates a Product', async () => {
  867. const result = await adminClient.query<
  868. Codegen.UpdateProductMutation,
  869. Codegen.UpdateProductMutationVariables
  870. >(UPDATE_PRODUCT, {
  871. input: {
  872. id: newProduct.id,
  873. translations: [
  874. {
  875. languageCode: LanguageCode.en,
  876. name: 'en Mashed Potato',
  877. slug: 'en-mashed-potato',
  878. description: 'A blob of mashed potato',
  879. },
  880. {
  881. languageCode: LanguageCode.de,
  882. name: 'de Mashed Potato',
  883. slug: 'de-mashed-potato',
  884. description: 'Eine blob von gemashed Erdapfel',
  885. },
  886. ],
  887. },
  888. });
  889. expect(result.updateProduct.translations.map(t => t.description).sort()).toEqual([
  890. 'A blob of mashed potato',
  891. 'Eine blob von gemashed Erdapfel',
  892. ]);
  893. });
  894. it('slug is normalized to be url-safe', async () => {
  895. const result = await adminClient.query<
  896. Codegen.UpdateProductMutation,
  897. Codegen.UpdateProductMutationVariables
  898. >(UPDATE_PRODUCT, {
  899. input: {
  900. id: newProduct.id,
  901. translations: [
  902. {
  903. languageCode: LanguageCode.en,
  904. name: 'en Mashed Potato',
  905. slug: 'A (very) nice potato!!',
  906. description: 'A blob of mashed potato',
  907. },
  908. ],
  909. },
  910. });
  911. expect(result.updateProduct.slug).toBe('a-very-nice-potato');
  912. });
  913. it('create with duplicate slug is renamed to be unique', async () => {
  914. const result = await adminClient.query<
  915. Codegen.CreateProductMutation,
  916. Codegen.CreateProductMutationVariables
  917. >(CREATE_PRODUCT, {
  918. input: {
  919. translations: [
  920. {
  921. languageCode: LanguageCode.en,
  922. name: 'Another baked potato',
  923. slug: 'a-very-nice-potato',
  924. description: 'Another baked potato but a bit different',
  925. },
  926. ],
  927. },
  928. });
  929. expect(result.createProduct.slug).toBe('a-very-nice-potato-2');
  930. });
  931. it('update with duplicate slug is renamed to be unique', async () => {
  932. const result = await adminClient.query<
  933. Codegen.UpdateProductMutation,
  934. Codegen.UpdateProductMutationVariables
  935. >(UPDATE_PRODUCT, {
  936. input: {
  937. id: newProduct.id,
  938. translations: [
  939. {
  940. languageCode: LanguageCode.en,
  941. name: 'Yet another baked potato',
  942. slug: 'a-very-nice-potato-2',
  943. description: 'Possibly the final baked potato',
  944. },
  945. ],
  946. },
  947. });
  948. expect(result.updateProduct.slug).toBe('a-very-nice-potato-3');
  949. });
  950. it('slug duplicate check does not include self', async () => {
  951. const result = await adminClient.query<
  952. Codegen.UpdateProductMutation,
  953. Codegen.UpdateProductMutationVariables
  954. >(UPDATE_PRODUCT, {
  955. input: {
  956. id: newProduct.id,
  957. translations: [
  958. {
  959. languageCode: LanguageCode.en,
  960. slug: 'a-very-nice-potato-3',
  961. },
  962. ],
  963. },
  964. });
  965. expect(result.updateProduct.slug).toBe('a-very-nice-potato-3');
  966. });
  967. it('updateProduct accepts partial input', async () => {
  968. const result = await adminClient.query<
  969. Codegen.UpdateProductMutation,
  970. Codegen.UpdateProductMutationVariables
  971. >(UPDATE_PRODUCT, {
  972. input: {
  973. id: newProduct.id,
  974. translations: [
  975. {
  976. languageCode: LanguageCode.en,
  977. name: 'en Very Mashed Potato',
  978. },
  979. ],
  980. },
  981. });
  982. expect(result.updateProduct.translations.length).toBe(2);
  983. expect(
  984. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.de)!.name,
  985. ).toBe('de Mashed Potato');
  986. expect(
  987. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.name,
  988. ).toBe('en Very Mashed Potato');
  989. expect(
  990. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.description,
  991. ).toBe('Possibly the final baked potato');
  992. });
  993. it('updateProduct adds Assets to a product and sets featured asset', async () => {
  994. const assetsResult = await adminClient.query<
  995. Codegen.GetAssetListQuery,
  996. Codegen.GetAssetListQueryVariables
  997. >(GET_ASSET_LIST);
  998. const assetIds = assetsResult.assets.items.map(a => a.id);
  999. const featuredAssetId = assetsResult.assets.items[2].id;
  1000. const result = await adminClient.query<
  1001. Codegen.UpdateProductMutation,
  1002. Codegen.UpdateProductMutationVariables
  1003. >(UPDATE_PRODUCT, {
  1004. input: {
  1005. id: newProduct.id,
  1006. assetIds,
  1007. featuredAssetId,
  1008. },
  1009. });
  1010. expect(result.updateProduct.assets.map(a => a.id)).toEqual(assetIds);
  1011. expect(result.updateProduct.featuredAsset!.id).toBe(featuredAssetId);
  1012. });
  1013. it('updateProduct sets a featured asset', async () => {
  1014. const productResult = await adminClient.query<
  1015. Codegen.GetProductWithVariantsQuery,
  1016. Codegen.GetProductWithVariantsQueryVariables
  1017. >(GET_PRODUCT_WITH_VARIANTS, {
  1018. id: newProduct.id,
  1019. });
  1020. const assets = productResult.product!.assets;
  1021. const result = await adminClient.query<
  1022. Codegen.UpdateProductMutation,
  1023. Codegen.UpdateProductMutationVariables
  1024. >(UPDATE_PRODUCT, {
  1025. input: {
  1026. id: newProduct.id,
  1027. featuredAssetId: assets[0].id,
  1028. },
  1029. });
  1030. expect(result.updateProduct.featuredAsset!.id).toBe(assets[0].id);
  1031. });
  1032. it('updateProduct updates assets', async () => {
  1033. const result = await adminClient.query<
  1034. Codegen.UpdateProductMutation,
  1035. Codegen.UpdateProductMutationVariables
  1036. >(UPDATE_PRODUCT, {
  1037. input: {
  1038. id: newProduct.id,
  1039. featuredAssetId: 'T_1',
  1040. assetIds: ['T_1', 'T_2'],
  1041. },
  1042. });
  1043. expect(result.updateProduct.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
  1044. });
  1045. it('updateProduct updates FacetValues', async () => {
  1046. const result = await adminClient.query<
  1047. Codegen.UpdateProductMutation,
  1048. Codegen.UpdateProductMutationVariables
  1049. >(UPDATE_PRODUCT, {
  1050. input: {
  1051. id: newProduct.id,
  1052. facetValueIds: ['T_1'],
  1053. },
  1054. });
  1055. expect(result.updateProduct.facetValues.length).toEqual(1);
  1056. });
  1057. it(
  1058. 'updateProduct errors with an invalid productId',
  1059. assertThrowsWithMessage(
  1060. () =>
  1061. adminClient.query<Codegen.UpdateProductMutation, Codegen.UpdateProductMutationVariables>(
  1062. UPDATE_PRODUCT,
  1063. {
  1064. input: {
  1065. id: '999',
  1066. translations: [
  1067. {
  1068. languageCode: LanguageCode.en,
  1069. name: 'en Mashed Potato',
  1070. slug: 'en-mashed-potato',
  1071. description: 'A blob of mashed potato',
  1072. },
  1073. {
  1074. languageCode: LanguageCode.de,
  1075. name: 'de Mashed Potato',
  1076. slug: 'de-mashed-potato',
  1077. description: 'Eine blob von gemashed Erdapfel',
  1078. },
  1079. ],
  1080. },
  1081. },
  1082. ),
  1083. `No Product with the id '999' could be found`,
  1084. ),
  1085. );
  1086. it('addOptionGroupToProduct adds an option group', async () => {
  1087. const result = await adminClient.query<
  1088. Codegen.AddOptionGroupToProductMutation,
  1089. Codegen.AddOptionGroupToProductMutationVariables
  1090. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1091. optionGroupId: 'T_2',
  1092. productId: newProduct.id,
  1093. });
  1094. expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
  1095. expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_2');
  1096. });
  1097. it(
  1098. 'addOptionGroupToProduct errors with an invalid productId',
  1099. assertThrowsWithMessage(
  1100. () =>
  1101. adminClient.query<
  1102. Codegen.AddOptionGroupToProductMutation,
  1103. Codegen.AddOptionGroupToProductMutationVariables
  1104. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1105. optionGroupId: 'T_1',
  1106. productId: '999',
  1107. }),
  1108. `No Product with the id '999' could be found`,
  1109. ),
  1110. );
  1111. it(
  1112. 'addOptionGroupToProduct errors with an invalid optionGroupId',
  1113. assertThrowsWithMessage(
  1114. () =>
  1115. adminClient.query<
  1116. Codegen.AddOptionGroupToProductMutation,
  1117. Codegen.AddOptionGroupToProductMutationVariables
  1118. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1119. optionGroupId: '999',
  1120. productId: newProduct.id,
  1121. }),
  1122. `No ProductOptionGroup with the id '999' could be found`,
  1123. ),
  1124. );
  1125. it('removeOptionGroupFromProduct removes an option group', async () => {
  1126. const { addOptionGroupToProduct } = await adminClient.query<
  1127. Codegen.AddOptionGroupToProductMutation,
  1128. Codegen.AddOptionGroupToProductMutationVariables
  1129. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1130. optionGroupId: 'T_1',
  1131. productId: newProductWithAssets.id,
  1132. });
  1133. expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
  1134. const { removeOptionGroupFromProduct } = await adminClient.query<
  1135. Codegen.RemoveOptionGroupFromProductMutation,
  1136. Codegen.RemoveOptionGroupFromProductMutationVariables
  1137. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1138. optionGroupId: 'T_1',
  1139. productId: newProductWithAssets.id,
  1140. });
  1141. removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
  1142. expect(removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
  1143. expect(removeOptionGroupFromProduct.optionGroups.length).toBe(0);
  1144. });
  1145. it('removeOptionGroupFromProduct return error result if the optionGroup is being used by variants', async () => {
  1146. const { removeOptionGroupFromProduct } = await adminClient.query<
  1147. Codegen.RemoveOptionGroupFromProductMutation,
  1148. Codegen.RemoveOptionGroupFromProductMutationVariables
  1149. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1150. optionGroupId: 'T_3',
  1151. productId: 'T_2',
  1152. });
  1153. removeOptionGuard.assertErrorResult(removeOptionGroupFromProduct);
  1154. expect(removeOptionGroupFromProduct.message).toBe(
  1155. `Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants`,
  1156. );
  1157. expect(removeOptionGroupFromProduct.errorCode).toBe(ErrorCode.PRODUCT_OPTION_IN_USE_ERROR);
  1158. expect(removeOptionGroupFromProduct.optionGroupCode).toBe('curvy-monitor-monitor-size');
  1159. expect(removeOptionGroupFromProduct.productVariantCount).toBe(2);
  1160. });
  1161. it(
  1162. 'removeOptionGroupFromProduct errors with an invalid productId',
  1163. assertThrowsWithMessage(
  1164. () =>
  1165. adminClient.query<
  1166. Codegen.RemoveOptionGroupFromProductMutation,
  1167. Codegen.RemoveOptionGroupFromProductMutationVariables
  1168. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1169. optionGroupId: '1',
  1170. productId: '999',
  1171. }),
  1172. `No Product with the id '999' could be found`,
  1173. ),
  1174. );
  1175. it(
  1176. 'removeOptionGroupFromProduct errors with an invalid optionGroupId',
  1177. assertThrowsWithMessage(
  1178. () =>
  1179. adminClient.query<
  1180. Codegen.RemoveOptionGroupFromProductMutation,
  1181. Codegen.RemoveOptionGroupFromProductMutationVariables
  1182. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1183. optionGroupId: '999',
  1184. productId: newProduct.id,
  1185. }),
  1186. `No ProductOptionGroup with the id '999' could be found`,
  1187. ),
  1188. );
  1189. describe('variants', () => {
  1190. let variants: Codegen.CreateProductVariantsMutation['createProductVariants'];
  1191. let optionGroup2: NonNullable<Codegen.GetOptionGroupQuery['productOptionGroup']>;
  1192. let optionGroup3: NonNullable<Codegen.GetOptionGroupQuery['productOptionGroup']>;
  1193. beforeAll(async () => {
  1194. await adminClient.query<
  1195. Codegen.AddOptionGroupToProductMutation,
  1196. Codegen.AddOptionGroupToProductMutationVariables
  1197. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1198. optionGroupId: 'T_3',
  1199. productId: newProduct.id,
  1200. });
  1201. const result1 = await adminClient.query<
  1202. Codegen.GetOptionGroupQuery,
  1203. Codegen.GetOptionGroupQueryVariables
  1204. >(GET_OPTION_GROUP, { id: 'T_2' });
  1205. const result2 = await adminClient.query<
  1206. Codegen.GetOptionGroupQuery,
  1207. Codegen.GetOptionGroupQueryVariables
  1208. >(GET_OPTION_GROUP, { id: 'T_3' });
  1209. optionGroup2 = result1.productOptionGroup!;
  1210. optionGroup3 = result2.productOptionGroup!;
  1211. });
  1212. it(
  1213. 'createProductVariants throws if optionIds not compatible with product',
  1214. assertThrowsWithMessage(async () => {
  1215. await adminClient.query<
  1216. Codegen.CreateProductVariantsMutation,
  1217. Codegen.CreateProductVariantsMutationVariables
  1218. >(CREATE_PRODUCT_VARIANTS, {
  1219. input: [
  1220. {
  1221. productId: newProduct.id,
  1222. sku: 'PV1',
  1223. optionIds: [],
  1224. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  1225. },
  1226. ],
  1227. });
  1228. }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
  1229. );
  1230. it(
  1231. 'createProductVariants throws if optionIds are duplicated',
  1232. assertThrowsWithMessage(async () => {
  1233. await adminClient.query<
  1234. Codegen.CreateProductVariantsMutation,
  1235. Codegen.CreateProductVariantsMutationVariables
  1236. >(CREATE_PRODUCT_VARIANTS, {
  1237. input: [
  1238. {
  1239. productId: newProduct.id,
  1240. sku: 'PV1',
  1241. optionIds: [optionGroup2.options[0].id, optionGroup2.options[1].id],
  1242. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  1243. },
  1244. ],
  1245. });
  1246. }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
  1247. );
  1248. it('createProductVariants works', async () => {
  1249. const { createProductVariants } = await adminClient.query<
  1250. Codegen.CreateProductVariantsMutation,
  1251. Codegen.CreateProductVariantsMutationVariables
  1252. >(CREATE_PRODUCT_VARIANTS, {
  1253. input: [
  1254. {
  1255. productId: newProduct.id,
  1256. sku: 'PV1',
  1257. optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
  1258. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  1259. },
  1260. ],
  1261. });
  1262. expect(createProductVariants[0]!.name).toBe('Variant 1');
  1263. expect(createProductVariants[0]!.options.map(pick(['id']))).toContainEqual({
  1264. id: optionGroup2.options[0].id,
  1265. });
  1266. expect(createProductVariants[0]!.options.map(pick(['id']))).toContainEqual({
  1267. id: optionGroup3.options[0].id,
  1268. });
  1269. });
  1270. it('createProductVariants adds multiple variants at once', async () => {
  1271. const { createProductVariants } = await adminClient.query<
  1272. Codegen.CreateProductVariantsMutation,
  1273. Codegen.CreateProductVariantsMutationVariables
  1274. >(CREATE_PRODUCT_VARIANTS, {
  1275. input: [
  1276. {
  1277. productId: newProduct.id,
  1278. sku: 'PV2',
  1279. optionIds: [optionGroup2.options[1].id, optionGroup3.options[0].id],
  1280. translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
  1281. },
  1282. {
  1283. productId: newProduct.id,
  1284. sku: 'PV3',
  1285. optionIds: [optionGroup2.options[1].id, optionGroup3.options[1].id],
  1286. translations: [{ languageCode: LanguageCode.en, name: 'Variant 3' }],
  1287. },
  1288. ],
  1289. });
  1290. const variant2 = createProductVariants.find(v => v!.name === 'Variant 2')!;
  1291. const variant3 = createProductVariants.find(v => v!.name === 'Variant 3')!;
  1292. expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
  1293. expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup3.options[0].id });
  1294. expect(variant3.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
  1295. expect(variant3.options.map(pick(['id']))).toContainEqual({ id: optionGroup3.options[1].id });
  1296. variants = createProductVariants.filter(notNullOrUndefined);
  1297. });
  1298. it(
  1299. 'createProductVariants throws if options combination already exists',
  1300. assertThrowsWithMessage(async () => {
  1301. await adminClient.query<
  1302. Codegen.CreateProductVariantsMutation,
  1303. Codegen.CreateProductVariantsMutationVariables
  1304. >(CREATE_PRODUCT_VARIANTS, {
  1305. input: [
  1306. {
  1307. productId: newProduct.id,
  1308. sku: 'PV2',
  1309. optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
  1310. translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
  1311. },
  1312. ],
  1313. });
  1314. }, 'A ProductVariant with the selected options already exists: Variant 1'),
  1315. );
  1316. it('updateProductVariants updates variants', async () => {
  1317. const firstVariant = variants[0];
  1318. const { updateProductVariants } = await adminClient.query<
  1319. Codegen.UpdateProductVariantsMutation,
  1320. Codegen.UpdateProductVariantsMutationVariables
  1321. >(UPDATE_PRODUCT_VARIANTS, {
  1322. input: [
  1323. {
  1324. id: firstVariant!.id,
  1325. translations: firstVariant!.translations,
  1326. sku: 'ABC',
  1327. price: 432,
  1328. },
  1329. ],
  1330. });
  1331. const updatedVariant = updateProductVariants[0];
  1332. if (!updatedVariant) {
  1333. fail('no updated variant returned.');
  1334. return;
  1335. }
  1336. expect(updatedVariant.sku).toBe('ABC');
  1337. expect(updatedVariant.price).toBe(432);
  1338. });
  1339. // https://github.com/vendure-ecommerce/vendure/issues/1101
  1340. it('after update, the updatedAt should be modified', async () => {
  1341. // Pause for a second to ensure the updatedAt date is more than 1s
  1342. // later than the createdAt date, since sqlite does not seem to store
  1343. // down to millisecond resolution.
  1344. await new Promise(resolve => setTimeout(resolve, 1000));
  1345. const firstVariant = variants[0];
  1346. const { updateProductVariants } = await adminClient.query<
  1347. Codegen.UpdateProductVariantsMutation,
  1348. Codegen.UpdateProductVariantsMutationVariables
  1349. >(UPDATE_PRODUCT_VARIANTS, {
  1350. input: [
  1351. {
  1352. id: firstVariant!.id,
  1353. translations: firstVariant!.translations,
  1354. sku: 'ABCD',
  1355. price: 432,
  1356. },
  1357. ],
  1358. });
  1359. const updatedVariant = updateProductVariants.find(v => v?.id === variants[0]!.id);
  1360. expect(updatedVariant?.updatedAt).not.toBe(updatedVariant?.createdAt);
  1361. });
  1362. it('updateProductVariants updates assets', async () => {
  1363. const firstVariant = variants[0];
  1364. const result = await adminClient.query<
  1365. Codegen.UpdateProductVariantsMutation,
  1366. Codegen.UpdateProductVariantsMutationVariables
  1367. >(UPDATE_PRODUCT_VARIANTS, {
  1368. input: [
  1369. {
  1370. id: firstVariant!.id,
  1371. assetIds: ['T_1', 'T_2'],
  1372. featuredAssetId: 'T_2',
  1373. },
  1374. ],
  1375. });
  1376. const updatedVariant = result.updateProductVariants[0];
  1377. if (!updatedVariant) {
  1378. fail('no updated variant returned.');
  1379. return;
  1380. }
  1381. expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
  1382. expect(updatedVariant.featuredAsset!.id).toBe('T_2');
  1383. });
  1384. it('updateProductVariants updates assets again', async () => {
  1385. const firstVariant = variants[0];
  1386. const result = await adminClient.query<
  1387. Codegen.UpdateProductVariantsMutation,
  1388. Codegen.UpdateProductVariantsMutationVariables
  1389. >(UPDATE_PRODUCT_VARIANTS, {
  1390. input: [
  1391. {
  1392. id: firstVariant!.id,
  1393. assetIds: ['T_4', 'T_3'],
  1394. featuredAssetId: 'T_4',
  1395. },
  1396. ],
  1397. });
  1398. const updatedVariant = result.updateProductVariants[0];
  1399. if (!updatedVariant) {
  1400. fail('no updated variant returned.');
  1401. return;
  1402. }
  1403. expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_4', 'T_3']);
  1404. expect(updatedVariant.featuredAsset!.id).toBe('T_4');
  1405. });
  1406. it('updateProductVariants updates taxCategory and price', async () => {
  1407. const firstVariant = variants[0];
  1408. const result = await adminClient.query<
  1409. Codegen.UpdateProductVariantsMutation,
  1410. Codegen.UpdateProductVariantsMutationVariables
  1411. >(UPDATE_PRODUCT_VARIANTS, {
  1412. input: [
  1413. {
  1414. id: firstVariant!.id,
  1415. price: 105,
  1416. taxCategoryId: 'T_2',
  1417. },
  1418. ],
  1419. });
  1420. const updatedVariant = result.updateProductVariants[0];
  1421. if (!updatedVariant) {
  1422. fail('no updated variant returned.');
  1423. return;
  1424. }
  1425. expect(updatedVariant.price).toBe(105);
  1426. expect(updatedVariant.taxCategory.id).toBe('T_2');
  1427. });
  1428. it('updateProductVariants updates facetValues', async () => {
  1429. const firstVariant = variants[0];
  1430. const result = await adminClient.query<
  1431. Codegen.UpdateProductVariantsMutation,
  1432. Codegen.UpdateProductVariantsMutationVariables
  1433. >(UPDATE_PRODUCT_VARIANTS, {
  1434. input: [
  1435. {
  1436. id: firstVariant!.id,
  1437. facetValueIds: ['T_1'],
  1438. },
  1439. ],
  1440. });
  1441. const updatedVariant = result.updateProductVariants[0];
  1442. if (!updatedVariant) {
  1443. fail('no updated variant returned.');
  1444. return;
  1445. }
  1446. expect(updatedVariant.facetValues.length).toBe(1);
  1447. expect(updatedVariant.facetValues[0].id).toBe('T_1');
  1448. });
  1449. it(
  1450. 'updateProductVariants throws with an invalid variant id',
  1451. assertThrowsWithMessage(
  1452. () =>
  1453. adminClient.query<
  1454. Codegen.UpdateProductVariantsMutation,
  1455. Codegen.UpdateProductVariantsMutationVariables
  1456. >(UPDATE_PRODUCT_VARIANTS, {
  1457. input: [
  1458. {
  1459. id: 'T_999',
  1460. translations: variants[0]!.translations,
  1461. sku: 'ABC',
  1462. price: 432,
  1463. },
  1464. ],
  1465. }),
  1466. `No ProductVariant with the id '999' could be found`,
  1467. ),
  1468. );
  1469. let deletedVariant: Codegen.ProductVariantFragment;
  1470. it('deleteProductVariant', async () => {
  1471. const result1 = await adminClient.query<
  1472. Codegen.GetProductWithVariantsQuery,
  1473. Codegen.GetProductWithVariantsQueryVariables
  1474. >(GET_PRODUCT_WITH_VARIANTS, {
  1475. id: newProduct.id,
  1476. });
  1477. const sortedVariantIds = result1.product!.variants.map(v => v.id).sort();
  1478. expect(sortedVariantIds).toEqual(['T_35', 'T_36', 'T_37']);
  1479. const { deleteProductVariant } = await adminClient.query<
  1480. Codegen.DeleteProductVariantMutation,
  1481. Codegen.DeleteProductVariantMutationVariables
  1482. >(DELETE_PRODUCT_VARIANT, {
  1483. id: sortedVariantIds[0],
  1484. });
  1485. expect(deleteProductVariant.result).toBe(DeletionResult.DELETED);
  1486. const result2 = await adminClient.query<
  1487. Codegen.GetProductWithVariantsQuery,
  1488. Codegen.GetProductWithVariantsQueryVariables
  1489. >(GET_PRODUCT_WITH_VARIANTS, {
  1490. id: newProduct.id,
  1491. });
  1492. expect(result2.product!.variants.map(v => v.id).sort()).toEqual(['T_36', 'T_37']);
  1493. deletedVariant = result1.product?.variants.find(v => v.id === 'T_35')!;
  1494. });
  1495. /** Testing https://github.com/vendure-ecommerce/vendure/issues/412 **/
  1496. it('createProductVariants ignores deleted variants when checking for existing combinations', async () => {
  1497. const { createProductVariants } = await adminClient.query<
  1498. Codegen.CreateProductVariantsMutation,
  1499. Codegen.CreateProductVariantsMutationVariables
  1500. >(CREATE_PRODUCT_VARIANTS, {
  1501. input: [
  1502. {
  1503. productId: newProduct.id,
  1504. sku: 'RE1',
  1505. optionIds: [deletedVariant.options[0].id, deletedVariant.options[1].id],
  1506. translations: [{ languageCode: LanguageCode.en, name: 'Re-created Variant' }],
  1507. },
  1508. ],
  1509. });
  1510. expect(createProductVariants.length).toBe(1);
  1511. expect(createProductVariants[0]!.options.map(o => o.code).sort()).toEqual(
  1512. deletedVariant.options.map(o => o.code).sort(),
  1513. );
  1514. });
  1515. // https://github.com/vendure-ecommerce/vendure/issues/980
  1516. it('creating variants in a non-default language', async () => {
  1517. const { createProduct } = await adminClient.query<
  1518. Codegen.CreateProductMutation,
  1519. Codegen.CreateProductMutationVariables
  1520. >(CREATE_PRODUCT, {
  1521. input: {
  1522. translations: [
  1523. {
  1524. languageCode: LanguageCode.de,
  1525. name: 'Ananas',
  1526. slug: 'ananas',
  1527. description: 'Yummy Ananas',
  1528. },
  1529. ],
  1530. },
  1531. });
  1532. const { createProductVariants } = await adminClient.query<
  1533. Codegen.CreateProductVariantsMutation,
  1534. Codegen.CreateProductVariantsMutationVariables
  1535. >(CREATE_PRODUCT_VARIANTS, {
  1536. input: [
  1537. {
  1538. productId: createProduct.id,
  1539. sku: 'AN1110111',
  1540. optionIds: [],
  1541. translations: [{ languageCode: LanguageCode.de, name: 'Ananas Klein' }],
  1542. },
  1543. ],
  1544. });
  1545. expect(createProductVariants.length).toBe(1);
  1546. expect(createProductVariants[0]?.name).toBe('Ananas Klein');
  1547. const { product } = await adminClient.query<
  1548. Codegen.GetProductWithVariantsQuery,
  1549. Codegen.GetProductWithVariantsQueryVariables
  1550. >(
  1551. GET_PRODUCT_WITH_VARIANTS,
  1552. {
  1553. id: createProduct.id,
  1554. },
  1555. { languageCode: LanguageCode.en },
  1556. );
  1557. expect(product?.variants.length).toBe(1);
  1558. });
  1559. });
  1560. });
  1561. describe('deletion', () => {
  1562. let allProducts: Codegen.GetProductListQuery['products']['items'];
  1563. let productToDelete: NonNullable<Codegen.GetProductWithVariantsQuery['product']>;
  1564. beforeAll(async () => {
  1565. const result = await adminClient.query<
  1566. Codegen.GetProductListQuery,
  1567. Codegen.GetProductListQueryVariables
  1568. >(GET_PRODUCT_LIST, {
  1569. options: {
  1570. sort: {
  1571. id: SortOrder.ASC,
  1572. },
  1573. },
  1574. });
  1575. allProducts = result.products.items;
  1576. });
  1577. it('deletes a product', async () => {
  1578. const { product } = await adminClient.query<
  1579. Codegen.GetProductWithVariantsQuery,
  1580. Codegen.GetProductWithVariantsQueryVariables
  1581. >(GET_PRODUCT_WITH_VARIANTS, {
  1582. id: allProducts[0].id,
  1583. });
  1584. const result = await adminClient.query<
  1585. Codegen.DeleteProductMutation,
  1586. Codegen.DeleteProductMutationVariables
  1587. >(DELETE_PRODUCT, { id: product!.id });
  1588. expect(result.deleteProduct).toEqual({ result: DeletionResult.DELETED });
  1589. productToDelete = product!;
  1590. });
  1591. it('cannot get a deleted product', async () => {
  1592. const { product } = await adminClient.query<
  1593. Codegen.GetProductWithVariantsQuery,
  1594. Codegen.GetProductWithVariantsQueryVariables
  1595. >(GET_PRODUCT_WITH_VARIANTS, {
  1596. id: productToDelete.id,
  1597. });
  1598. expect(product).toBe(null);
  1599. });
  1600. // https://github.com/vendure-ecommerce/vendure/issues/1096
  1601. it('variants of deleted product are also deleted', async () => {
  1602. for (const variant of productToDelete.variants) {
  1603. const { productVariant } = await adminClient.query<
  1604. Codegen.GetProductVariantQuery,
  1605. Codegen.GetProductVariantQueryVariables
  1606. >(GET_PRODUCT_VARIANT, {
  1607. id: variant.id,
  1608. });
  1609. expect(productVariant).toBe(null);
  1610. }
  1611. });
  1612. it('deleted product omitted from list', async () => {
  1613. const result = await adminClient.query<Codegen.GetProductListQuery>(GET_PRODUCT_LIST);
  1614. expect(result.products.items.length).toBe(allProducts.length - 1);
  1615. expect(result.products.items.map(c => c.id).includes(productToDelete.id)).toBe(false);
  1616. });
  1617. it(
  1618. 'updateProduct throws for deleted product',
  1619. assertThrowsWithMessage(
  1620. () =>
  1621. adminClient.query<Codegen.UpdateProductMutation, Codegen.UpdateProductMutationVariables>(
  1622. UPDATE_PRODUCT,
  1623. {
  1624. input: {
  1625. id: productToDelete.id,
  1626. facetValueIds: ['T_1'],
  1627. },
  1628. },
  1629. ),
  1630. `No Product with the id '1' could be found`,
  1631. ),
  1632. );
  1633. it(
  1634. 'addOptionGroupToProduct throws for deleted product',
  1635. assertThrowsWithMessage(
  1636. () =>
  1637. adminClient.query<
  1638. Codegen.AddOptionGroupToProductMutation,
  1639. Codegen.AddOptionGroupToProductMutationVariables
  1640. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  1641. optionGroupId: 'T_1',
  1642. productId: productToDelete.id,
  1643. }),
  1644. `No Product with the id '1' could be found`,
  1645. ),
  1646. );
  1647. it(
  1648. 'removeOptionGroupToProduct throws for deleted product',
  1649. assertThrowsWithMessage(
  1650. () =>
  1651. adminClient.query<
  1652. Codegen.RemoveOptionGroupFromProductMutation,
  1653. Codegen.RemoveOptionGroupFromProductMutationVariables
  1654. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1655. optionGroupId: 'T_1',
  1656. productId: productToDelete.id,
  1657. }),
  1658. `No Product with the id '1' could be found`,
  1659. ),
  1660. );
  1661. // https://github.com/vendure-ecommerce/vendure/issues/558
  1662. it('slug of a deleted product can be re-used', async () => {
  1663. const result = await adminClient.query<
  1664. Codegen.CreateProductMutation,
  1665. Codegen.CreateProductMutationVariables
  1666. >(CREATE_PRODUCT, {
  1667. input: {
  1668. translations: [
  1669. {
  1670. languageCode: LanguageCode.en,
  1671. name: 'Product reusing deleted slug',
  1672. slug: productToDelete.slug,
  1673. description: 'stuff',
  1674. },
  1675. ],
  1676. },
  1677. });
  1678. expect(result.createProduct.slug).toBe(productToDelete.slug);
  1679. });
  1680. // https://github.com/vendure-ecommerce/vendure/issues/1505
  1681. it('attempting to re-use deleted slug twice is not allowed', async () => {
  1682. const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
  1683. CREATE_PRODUCT,
  1684. {
  1685. input: {
  1686. translations: [
  1687. {
  1688. languageCode: LanguageCode.en,
  1689. name: 'Product reusing deleted slug',
  1690. slug: productToDelete.slug,
  1691. description: 'stuff',
  1692. },
  1693. ],
  1694. },
  1695. },
  1696. );
  1697. expect(result.createProduct.slug).not.toBe(productToDelete.slug);
  1698. expect(result.createProduct.slug).toBe('laptop-2');
  1699. });
  1700. // https://github.com/vendure-ecommerce/vendure/issues/800
  1701. it('product can be fetched by slug of a deleted product', async () => {
  1702. const { product } = await adminClient.query<
  1703. Codegen.GetProductSimpleQuery,
  1704. Codegen.GetProductSimpleQueryVariables
  1705. >(GET_PRODUCT_SIMPLE, { slug: productToDelete.slug });
  1706. if (!product) {
  1707. fail('Product not found');
  1708. return;
  1709. }
  1710. expect(product.slug).toBe(productToDelete.slug);
  1711. });
  1712. });
  1713. });
  1714. export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
  1715. mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
  1716. removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {
  1717. ...ProductWithOptions
  1718. ... on ProductOptionInUseError {
  1719. errorCode
  1720. message
  1721. optionGroupCode
  1722. productVariantCount
  1723. }
  1724. }
  1725. }
  1726. ${PRODUCT_WITH_OPTIONS_FRAGMENT}
  1727. `;
  1728. export const GET_OPTION_GROUP = gql`
  1729. query GetOptionGroup($id: ID!) {
  1730. productOptionGroup(id: $id) {
  1731. id
  1732. code
  1733. options {
  1734. id
  1735. code
  1736. }
  1737. }
  1738. }
  1739. `;
  1740. export const GET_PRODUCT_VARIANT = gql`
  1741. query GetProductVariant($id: ID!) {
  1742. productVariant(id: $id) {
  1743. id
  1744. name
  1745. }
  1746. }
  1747. `;
  1748. export const GET_PRODUCT_VARIANT_LIST = gql`
  1749. query GetProductVariantLIST($options: ProductVariantListOptions, $productId: ID) {
  1750. productVariants(options: $options, productId: $productId) {
  1751. items {
  1752. id
  1753. name
  1754. sku
  1755. price
  1756. priceWithTax
  1757. }
  1758. totalItems
  1759. }
  1760. }
  1761. `;
  1762. export const GET_PRODUCT_WITH_VARIANT_LIST = gql`
  1763. query GetProductWithVariantList($id: ID, $variantListOptions: ProductVariantListOptions) {
  1764. product(id: $id) {
  1765. id
  1766. variantList(options: $variantListOptions) {
  1767. items {
  1768. ...ProductVariant
  1769. }
  1770. totalItems
  1771. }
  1772. }
  1773. }
  1774. ${PRODUCT_VARIANT_FRAGMENT}
  1775. `;