product.e2e-spec.ts 78 KB

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