product.e2e-spec.ts 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  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 { createTestEnvironment } 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  9. import {
  10. AddOptionGroupToProduct,
  11. CreateProduct,
  12. CreateProductVariants,
  13. DeleteProduct,
  14. DeleteProductVariant,
  15. DeletionResult,
  16. GetAssetList,
  17. GetOptionGroup,
  18. GetProductList,
  19. GetProductSimple,
  20. GetProductWithVariants,
  21. LanguageCode,
  22. ProductWithVariants,
  23. RemoveOptionGroupFromProduct,
  24. SortOrder,
  25. UpdateProduct,
  26. UpdateProductVariants,
  27. } from './graphql/generated-e2e-admin-types';
  28. import {
  29. CREATE_PRODUCT,
  30. CREATE_PRODUCT_VARIANTS,
  31. DELETE_PRODUCT,
  32. DELETE_PRODUCT_VARIANT,
  33. GET_ASSET_LIST,
  34. GET_PRODUCT_LIST,
  35. GET_PRODUCT_SIMPLE,
  36. GET_PRODUCT_WITH_VARIANTS,
  37. UPDATE_PRODUCT,
  38. UPDATE_PRODUCT_VARIANTS,
  39. } from './graphql/shared-definitions';
  40. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  41. import { sortById } from './utils/test-order-utils';
  42. // tslint:disable:no-non-null-assertion
  43. describe('Product resolver', () => {
  44. const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
  45. beforeAll(async () => {
  46. await server.init({
  47. initialData,
  48. customerCount: 1,
  49. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  50. });
  51. await adminClient.asSuperAdmin();
  52. }, TEST_SETUP_TIMEOUT_MS);
  53. afterAll(async () => {
  54. await server.destroy();
  55. });
  56. describe('products list query', () => {
  57. it('returns all products when no options passed', async () => {
  58. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  59. GET_PRODUCT_LIST,
  60. {},
  61. );
  62. expect(result.products.items.length).toBe(20);
  63. expect(result.products.totalItems).toBe(20);
  64. });
  65. it('limits result set with skip & take', async () => {
  66. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  67. GET_PRODUCT_LIST,
  68. {
  69. options: {
  70. skip: 0,
  71. take: 3,
  72. },
  73. },
  74. );
  75. expect(result.products.items.length).toBe(3);
  76. expect(result.products.totalItems).toBe(20);
  77. });
  78. it('filters by name admin', async () => {
  79. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  80. GET_PRODUCT_LIST,
  81. {
  82. options: {
  83. filter: {
  84. name: {
  85. contains: 'skateboard',
  86. },
  87. },
  88. },
  89. },
  90. );
  91. expect(result.products.items.length).toBe(1);
  92. expect(result.products.items[0].name).toBe('Cruiser Skateboard');
  93. });
  94. it('filters multiple admin', async () => {
  95. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  96. GET_PRODUCT_LIST,
  97. {
  98. options: {
  99. filter: {
  100. name: {
  101. contains: 'camera',
  102. },
  103. slug: {
  104. contains: 'tent',
  105. },
  106. },
  107. },
  108. },
  109. );
  110. expect(result.products.items.length).toBe(0);
  111. });
  112. it('sorts by name admin', async () => {
  113. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  114. GET_PRODUCT_LIST,
  115. {
  116. options: {
  117. sort: {
  118. name: SortOrder.ASC,
  119. },
  120. },
  121. },
  122. );
  123. expect(result.products.items.map(p => p.name)).toEqual([
  124. 'Bonsai Tree',
  125. 'Boxing Gloves',
  126. 'Camera Lens',
  127. 'Clacky Keyboard',
  128. 'Cruiser Skateboard',
  129. 'Curvy Monitor',
  130. 'Football',
  131. 'Gaming PC',
  132. 'Hard Drive',
  133. 'Instant Camera',
  134. 'Laptop',
  135. 'Orchid',
  136. 'Road Bike',
  137. 'Running Shoe',
  138. 'Skipping Rope',
  139. 'Slr Camera',
  140. 'Spiky Cactus',
  141. 'Tent',
  142. 'Tripod',
  143. 'USB Cable',
  144. ]);
  145. });
  146. it('filters by name shop', async () => {
  147. const result = await shopClient.query<GetProductList.Query, GetProductList.Variables>(
  148. GET_PRODUCT_LIST,
  149. {
  150. options: {
  151. filter: {
  152. name: {
  153. contains: 'skateboard',
  154. },
  155. },
  156. },
  157. },
  158. );
  159. expect(result.products.items.length).toBe(1);
  160. expect(result.products.items[0].name).toBe('Cruiser Skateboard');
  161. });
  162. it('sorts by name shop', async () => {
  163. const result = await shopClient.query<GetProductList.Query, GetProductList.Variables>(
  164. GET_PRODUCT_LIST,
  165. {
  166. options: {
  167. sort: {
  168. name: SortOrder.ASC,
  169. },
  170. },
  171. },
  172. );
  173. expect(result.products.items.map(p => p.name)).toEqual([
  174. 'Bonsai Tree',
  175. 'Boxing Gloves',
  176. 'Camera Lens',
  177. 'Clacky Keyboard',
  178. 'Cruiser Skateboard',
  179. 'Curvy Monitor',
  180. 'Football',
  181. 'Gaming PC',
  182. 'Hard Drive',
  183. 'Instant Camera',
  184. 'Laptop',
  185. 'Orchid',
  186. 'Road Bike',
  187. 'Running Shoe',
  188. 'Skipping Rope',
  189. 'Slr Camera',
  190. 'Spiky Cactus',
  191. 'Tent',
  192. 'Tripod',
  193. 'USB Cable',
  194. ]);
  195. });
  196. });
  197. describe('product query', () => {
  198. it('by id', async () => {
  199. const { product } = await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(
  200. GET_PRODUCT_SIMPLE,
  201. { id: 'T_2' },
  202. );
  203. if (!product) {
  204. fail('Product not found');
  205. return;
  206. }
  207. expect(product.id).toBe('T_2');
  208. });
  209. it('by slug', async () => {
  210. const { product } = await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(
  211. GET_PRODUCT_SIMPLE,
  212. { slug: 'curvy-monitor' },
  213. );
  214. if (!product) {
  215. fail('Product not found');
  216. return;
  217. }
  218. expect(product.slug).toBe('curvy-monitor');
  219. });
  220. it(
  221. 'throws if neither id nor slug provided',
  222. assertThrowsWithMessage(async () => {
  223. await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(
  224. GET_PRODUCT_SIMPLE,
  225. {},
  226. );
  227. }, 'Either the product id or slug must be provided'),
  228. );
  229. it(
  230. 'throws if id and slug do not refer to the same Product',
  231. assertThrowsWithMessage(async () => {
  232. await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(
  233. GET_PRODUCT_SIMPLE,
  234. {
  235. id: 'T_2',
  236. slug: 'laptop',
  237. },
  238. );
  239. }, 'The provided id and slug refer to different Products'),
  240. );
  241. it('returns expected properties', async () => {
  242. const { product } = await adminClient.query<
  243. GetProductWithVariants.Query,
  244. GetProductWithVariants.Variables
  245. >(GET_PRODUCT_WITH_VARIANTS, {
  246. id: 'T_2',
  247. });
  248. if (!product) {
  249. fail('Product not found');
  250. return;
  251. }
  252. expect(omit(product, ['variants'])).toMatchSnapshot();
  253. expect(product.variants.length).toBe(2);
  254. });
  255. it('ProductVariant price properties are correct', async () => {
  256. const result = await adminClient.query<
  257. GetProductWithVariants.Query,
  258. GetProductWithVariants.Variables
  259. >(GET_PRODUCT_WITH_VARIANTS, {
  260. id: 'T_2',
  261. });
  262. if (!result.product) {
  263. fail('Product not found');
  264. return;
  265. }
  266. expect(result.product.variants[0].price).toBe(14374);
  267. expect(result.product.variants[0].taxCategory).toEqual({
  268. id: 'T_1',
  269. name: 'Standard Tax',
  270. });
  271. });
  272. it('returns null when id not found', async () => {
  273. const result = await adminClient.query<
  274. GetProductWithVariants.Query,
  275. GetProductWithVariants.Variables
  276. >(GET_PRODUCT_WITH_VARIANTS, {
  277. id: 'bad_id',
  278. });
  279. expect(result.product).toBeNull();
  280. });
  281. });
  282. describe('product mutation', () => {
  283. let newProduct: ProductWithVariants.Fragment;
  284. let newProductWithAssets: ProductWithVariants.Fragment;
  285. it('createProduct creates a new Product', async () => {
  286. const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
  287. CREATE_PRODUCT,
  288. {
  289. input: {
  290. translations: [
  291. {
  292. languageCode: LanguageCode.en,
  293. name: 'en Baked Potato',
  294. slug: 'en Baked Potato',
  295. description: 'A baked potato',
  296. },
  297. {
  298. languageCode: LanguageCode.de,
  299. name: 'de Baked Potato',
  300. slug: 'de-baked-potato',
  301. description: 'Eine baked Erdapfel',
  302. },
  303. ],
  304. },
  305. },
  306. );
  307. expect(omit(result.createProduct, ['translations'])).toMatchSnapshot();
  308. expect(result.createProduct.translations.map(t => t.description).sort()).toEqual([
  309. 'A baked potato',
  310. 'Eine baked Erdapfel',
  311. ]);
  312. newProduct = result.createProduct;
  313. });
  314. it('createProduct creates a new Product with assets', async () => {
  315. const assetsResult = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
  316. GET_ASSET_LIST,
  317. );
  318. const assetIds = assetsResult.assets.items.slice(0, 2).map(a => a.id);
  319. const featuredAssetId = assetsResult.assets.items[0].id;
  320. const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
  321. CREATE_PRODUCT,
  322. {
  323. input: {
  324. assetIds,
  325. featuredAssetId,
  326. translations: [
  327. {
  328. languageCode: LanguageCode.en,
  329. name: 'en Has Assets',
  330. slug: 'en-has-assets',
  331. description: 'A product with assets',
  332. },
  333. ],
  334. },
  335. },
  336. );
  337. expect(result.createProduct.assets.map(a => a.id)).toEqual(assetIds);
  338. expect(result.createProduct.featuredAsset!.id).toBe(featuredAssetId);
  339. newProductWithAssets = result.createProduct;
  340. });
  341. it('updateProduct updates a Product', async () => {
  342. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  343. UPDATE_PRODUCT,
  344. {
  345. input: {
  346. id: newProduct.id,
  347. translations: [
  348. {
  349. languageCode: LanguageCode.en,
  350. name: 'en Mashed Potato',
  351. slug: 'en-mashed-potato',
  352. description: 'A blob of mashed potato',
  353. },
  354. {
  355. languageCode: LanguageCode.de,
  356. name: 'de Mashed Potato',
  357. slug: 'de-mashed-potato',
  358. description: 'Eine blob von gemashed Erdapfel',
  359. },
  360. ],
  361. },
  362. },
  363. );
  364. expect(result.updateProduct.translations.map(t => t.description).sort()).toEqual([
  365. 'A blob of mashed potato',
  366. 'Eine blob von gemashed Erdapfel',
  367. ]);
  368. });
  369. it('slug is normalized to be url-safe', async () => {
  370. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  371. UPDATE_PRODUCT,
  372. {
  373. input: {
  374. id: newProduct.id,
  375. translations: [
  376. {
  377. languageCode: LanguageCode.en,
  378. name: 'en Mashed Potato',
  379. slug: 'A (very) nice potato!!',
  380. description: 'A blob of mashed potato',
  381. },
  382. ],
  383. },
  384. },
  385. );
  386. expect(result.updateProduct.slug).toBe('a-very-nice-potato');
  387. });
  388. it('create with duplicate slug is renamed to be unique', async () => {
  389. const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
  390. CREATE_PRODUCT,
  391. {
  392. input: {
  393. translations: [
  394. {
  395. languageCode: LanguageCode.en,
  396. name: 'Another baked potato',
  397. slug: 'a-very-nice-potato',
  398. description: 'Another baked potato but a bit different',
  399. },
  400. ],
  401. },
  402. },
  403. );
  404. expect(result.createProduct.slug).toBe('a-very-nice-potato-2');
  405. });
  406. it('update with duplicate slug is renamed to be unique', async () => {
  407. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  408. UPDATE_PRODUCT,
  409. {
  410. input: {
  411. id: newProduct.id,
  412. translations: [
  413. {
  414. languageCode: LanguageCode.en,
  415. name: 'Yet another baked potato',
  416. slug: 'a-very-nice-potato-2',
  417. description: 'Possibly the final baked potato',
  418. },
  419. ],
  420. },
  421. },
  422. );
  423. expect(result.updateProduct.slug).toBe('a-very-nice-potato-3');
  424. });
  425. it('slug duplicate check does not include self', async () => {
  426. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  427. UPDATE_PRODUCT,
  428. {
  429. input: {
  430. id: newProduct.id,
  431. translations: [
  432. {
  433. languageCode: LanguageCode.en,
  434. slug: 'a-very-nice-potato-3',
  435. },
  436. ],
  437. },
  438. },
  439. );
  440. expect(result.updateProduct.slug).toBe('a-very-nice-potato-3');
  441. });
  442. it('updateProduct accepts partial input', async () => {
  443. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  444. UPDATE_PRODUCT,
  445. {
  446. input: {
  447. id: newProduct.id,
  448. translations: [
  449. {
  450. languageCode: LanguageCode.en,
  451. name: 'en Very Mashed Potato',
  452. },
  453. ],
  454. },
  455. },
  456. );
  457. expect(result.updateProduct.translations.length).toBe(2);
  458. expect(
  459. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.de)!.name,
  460. ).toBe('de Mashed Potato');
  461. expect(
  462. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.name,
  463. ).toBe('en Very Mashed Potato');
  464. expect(
  465. result.updateProduct.translations.find(t => t.languageCode === LanguageCode.en)!.description,
  466. ).toBe('Possibly the final baked potato');
  467. });
  468. it('updateProduct adds Assets to a product and sets featured asset', async () => {
  469. const assetsResult = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
  470. GET_ASSET_LIST,
  471. );
  472. const assetIds = assetsResult.assets.items.map(a => a.id);
  473. const featuredAssetId = assetsResult.assets.items[2].id;
  474. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  475. UPDATE_PRODUCT,
  476. {
  477. input: {
  478. id: newProduct.id,
  479. assetIds,
  480. featuredAssetId,
  481. },
  482. },
  483. );
  484. expect(result.updateProduct.assets.map(a => a.id)).toEqual(assetIds);
  485. expect(result.updateProduct.featuredAsset!.id).toBe(featuredAssetId);
  486. });
  487. it('updateProduct sets a featured asset', async () => {
  488. const productResult = await adminClient.query<
  489. GetProductWithVariants.Query,
  490. GetProductWithVariants.Variables
  491. >(GET_PRODUCT_WITH_VARIANTS, {
  492. id: newProduct.id,
  493. });
  494. const assets = productResult.product!.assets;
  495. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  496. UPDATE_PRODUCT,
  497. {
  498. input: {
  499. id: newProduct.id,
  500. featuredAssetId: assets[0].id,
  501. },
  502. },
  503. );
  504. expect(result.updateProduct.featuredAsset!.id).toBe(assets[0].id);
  505. });
  506. it('updateProduct updates assets', async () => {
  507. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  508. UPDATE_PRODUCT,
  509. {
  510. input: {
  511. id: newProduct.id,
  512. featuredAssetId: 'T_1',
  513. assetIds: ['T_1', 'T_2'],
  514. },
  515. },
  516. );
  517. expect(result.updateProduct.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
  518. });
  519. it('updateProduct updates FacetValues', async () => {
  520. const result = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
  521. UPDATE_PRODUCT,
  522. {
  523. input: {
  524. id: newProduct.id,
  525. facetValueIds: ['T_1'],
  526. },
  527. },
  528. );
  529. expect(result.updateProduct.facetValues.length).toEqual(1);
  530. });
  531. it(
  532. 'updateProduct errors with an invalid productId',
  533. assertThrowsWithMessage(
  534. () =>
  535. adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  536. input: {
  537. id: '999',
  538. translations: [
  539. {
  540. languageCode: LanguageCode.en,
  541. name: 'en Mashed Potato',
  542. slug: 'en-mashed-potato',
  543. description: 'A blob of mashed potato',
  544. },
  545. {
  546. languageCode: LanguageCode.de,
  547. name: 'de Mashed Potato',
  548. slug: 'de-mashed-potato',
  549. description: 'Eine blob von gemashed Erdapfel',
  550. },
  551. ],
  552. },
  553. }),
  554. `No Product with the id '999' could be found`,
  555. ),
  556. );
  557. it('addOptionGroupToProduct adds an option group', async () => {
  558. const result = await adminClient.query<
  559. AddOptionGroupToProduct.Mutation,
  560. AddOptionGroupToProduct.Variables
  561. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  562. optionGroupId: 'T_2',
  563. productId: newProduct.id,
  564. });
  565. expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
  566. expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_2');
  567. });
  568. it(
  569. 'addOptionGroupToProduct errors with an invalid productId',
  570. assertThrowsWithMessage(
  571. () =>
  572. adminClient.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
  573. ADD_OPTION_GROUP_TO_PRODUCT,
  574. {
  575. optionGroupId: 'T_1',
  576. productId: '999',
  577. },
  578. ),
  579. `No Product with the id '999' could be found`,
  580. ),
  581. );
  582. it(
  583. 'addOptionGroupToProduct errors with an invalid optionGroupId',
  584. assertThrowsWithMessage(
  585. () =>
  586. adminClient.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
  587. ADD_OPTION_GROUP_TO_PRODUCT,
  588. {
  589. optionGroupId: '999',
  590. productId: newProduct.id,
  591. },
  592. ),
  593. `No ProductOptionGroup with the id '999' could be found`,
  594. ),
  595. );
  596. it('removeOptionGroupFromProduct removes an option group', async () => {
  597. const { addOptionGroupToProduct } = await adminClient.query<
  598. AddOptionGroupToProduct.Mutation,
  599. AddOptionGroupToProduct.Variables
  600. >(ADD_OPTION_GROUP_TO_PRODUCT, {
  601. optionGroupId: 'T_1',
  602. productId: newProductWithAssets.id,
  603. });
  604. expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
  605. const result = await adminClient.query<
  606. RemoveOptionGroupFromProduct.Mutation,
  607. RemoveOptionGroupFromProduct.Variables
  608. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  609. optionGroupId: 'T_1',
  610. productId: newProductWithAssets.id,
  611. });
  612. expect(result.removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
  613. expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
  614. });
  615. it(
  616. 'removeOptionGroupFromProduct errors if the optionGroup is being used by variants',
  617. assertThrowsWithMessage(
  618. () =>
  619. adminClient.query<
  620. RemoveOptionGroupFromProduct.Mutation,
  621. RemoveOptionGroupFromProduct.Variables
  622. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  623. optionGroupId: 'T_3',
  624. productId: 'T_2',
  625. }),
  626. `Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants`,
  627. ),
  628. );
  629. it(
  630. 'removeOptionGroupFromProduct errors with an invalid productId',
  631. assertThrowsWithMessage(
  632. () =>
  633. adminClient.query<
  634. RemoveOptionGroupFromProduct.Mutation,
  635. RemoveOptionGroupFromProduct.Variables
  636. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  637. optionGroupId: '1',
  638. productId: '999',
  639. }),
  640. `No Product with the id '999' could be found`,
  641. ),
  642. );
  643. it(
  644. 'removeOptionGroupFromProduct errors with an invalid optionGroupId',
  645. assertThrowsWithMessage(
  646. () =>
  647. adminClient.query<
  648. RemoveOptionGroupFromProduct.Mutation,
  649. RemoveOptionGroupFromProduct.Variables
  650. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  651. optionGroupId: '999',
  652. productId: newProduct.id,
  653. }),
  654. `No ProductOptionGroup with the id '999' could be found`,
  655. ),
  656. );
  657. describe('variants', () => {
  658. let variants: CreateProductVariants.CreateProductVariants[];
  659. let optionGroup2: GetOptionGroup.ProductOptionGroup;
  660. let optionGroup3: GetOptionGroup.ProductOptionGroup;
  661. beforeAll(async () => {
  662. await adminClient.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
  663. ADD_OPTION_GROUP_TO_PRODUCT,
  664. {
  665. optionGroupId: 'T_3',
  666. productId: newProduct.id,
  667. },
  668. );
  669. const result1 = await adminClient.query<GetOptionGroup.Query, GetOptionGroup.Variables>(
  670. GET_OPTION_GROUP,
  671. { id: 'T_2' },
  672. );
  673. const result2 = await adminClient.query<GetOptionGroup.Query, GetOptionGroup.Variables>(
  674. GET_OPTION_GROUP,
  675. { id: 'T_3' },
  676. );
  677. optionGroup2 = result1.productOptionGroup!;
  678. optionGroup3 = result2.productOptionGroup!;
  679. });
  680. it(
  681. 'createProductVariants throws if optionIds not compatible with product',
  682. assertThrowsWithMessage(async () => {
  683. await adminClient.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
  684. CREATE_PRODUCT_VARIANTS,
  685. {
  686. input: [
  687. {
  688. productId: newProduct.id,
  689. sku: 'PV1',
  690. optionIds: [],
  691. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  692. },
  693. ],
  694. },
  695. );
  696. }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
  697. );
  698. it(
  699. 'createProductVariants throws if optionIds are duplicated',
  700. assertThrowsWithMessage(async () => {
  701. await adminClient.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
  702. CREATE_PRODUCT_VARIANTS,
  703. {
  704. input: [
  705. {
  706. productId: newProduct.id,
  707. sku: 'PV1',
  708. optionIds: [optionGroup2.options[0].id, optionGroup2.options[1].id],
  709. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  710. },
  711. ],
  712. },
  713. );
  714. }, 'ProductVariant optionIds must include one optionId from each of the groups: curvy-monitor-monitor-size, laptop-ram'),
  715. );
  716. it('createProductVariants works', async () => {
  717. const { createProductVariants } = await adminClient.query<
  718. CreateProductVariants.Mutation,
  719. CreateProductVariants.Variables
  720. >(CREATE_PRODUCT_VARIANTS, {
  721. input: [
  722. {
  723. productId: newProduct.id,
  724. sku: 'PV1',
  725. optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
  726. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  727. },
  728. ],
  729. });
  730. expect(createProductVariants[0]!.name).toBe('Variant 1');
  731. expect(createProductVariants[0]!.options.map(pick(['id']))).toContainEqual({
  732. id: optionGroup2.options[0].id,
  733. });
  734. expect(createProductVariants[0]!.options.map(pick(['id']))).toContainEqual({
  735. id: optionGroup3.options[0].id,
  736. });
  737. });
  738. it('createProductVariants adds multiple variants at once', async () => {
  739. const { createProductVariants } = await adminClient.query<
  740. CreateProductVariants.Mutation,
  741. CreateProductVariants.Variables
  742. >(CREATE_PRODUCT_VARIANTS, {
  743. input: [
  744. {
  745. productId: newProduct.id,
  746. sku: 'PV2',
  747. optionIds: [optionGroup2.options[1].id, optionGroup3.options[0].id],
  748. translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
  749. },
  750. {
  751. productId: newProduct.id,
  752. sku: 'PV3',
  753. optionIds: [optionGroup2.options[1].id, optionGroup3.options[1].id],
  754. translations: [{ languageCode: LanguageCode.en, name: 'Variant 3' }],
  755. },
  756. ],
  757. });
  758. const variant2 = createProductVariants.find(v => v!.name === 'Variant 2')!;
  759. const variant3 = createProductVariants.find(v => v!.name === 'Variant 3')!;
  760. expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
  761. expect(variant2.options.map(pick(['id']))).toContainEqual({ id: optionGroup3.options[0].id });
  762. expect(variant3.options.map(pick(['id']))).toContainEqual({ id: optionGroup2.options[1].id });
  763. expect(variant3.options.map(pick(['id']))).toContainEqual({ id: optionGroup3.options[1].id });
  764. variants = createProductVariants.filter(notNullOrUndefined);
  765. });
  766. it(
  767. 'createProductVariants throws if options combination already exists',
  768. assertThrowsWithMessage(async () => {
  769. await adminClient.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
  770. CREATE_PRODUCT_VARIANTS,
  771. {
  772. input: [
  773. {
  774. productId: newProduct.id,
  775. sku: 'PV2',
  776. optionIds: [optionGroup2.options[0].id, optionGroup3.options[0].id],
  777. translations: [{ languageCode: LanguageCode.en, name: 'Variant 2' }],
  778. },
  779. ],
  780. },
  781. );
  782. }, 'A ProductVariant already exists with the options:'),
  783. );
  784. it('updateProductVariants updates variants', async () => {
  785. const firstVariant = variants[0];
  786. const { updateProductVariants } = await adminClient.query<
  787. UpdateProductVariants.Mutation,
  788. UpdateProductVariants.Variables
  789. >(UPDATE_PRODUCT_VARIANTS, {
  790. input: [
  791. {
  792. id: firstVariant.id,
  793. translations: firstVariant.translations,
  794. sku: 'ABC',
  795. price: 432,
  796. },
  797. ],
  798. });
  799. const updatedVariant = updateProductVariants[0];
  800. if (!updatedVariant) {
  801. fail('no updated variant returned.');
  802. return;
  803. }
  804. expect(updatedVariant.sku).toBe('ABC');
  805. expect(updatedVariant.price).toBe(432);
  806. });
  807. it('updateProductVariants updates assets', async () => {
  808. const firstVariant = variants[0];
  809. const result = await adminClient.query<
  810. UpdateProductVariants.Mutation,
  811. UpdateProductVariants.Variables
  812. >(UPDATE_PRODUCT_VARIANTS, {
  813. input: [
  814. {
  815. id: firstVariant.id,
  816. assetIds: ['T_1', 'T_2'],
  817. featuredAssetId: 'T_2',
  818. },
  819. ],
  820. });
  821. const updatedVariant = result.updateProductVariants[0];
  822. if (!updatedVariant) {
  823. fail('no updated variant returned.');
  824. return;
  825. }
  826. expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_1', 'T_2']);
  827. expect(updatedVariant.featuredAsset!.id).toBe('T_2');
  828. });
  829. it('updateProductVariants updates assets again', async () => {
  830. const firstVariant = variants[0];
  831. const result = await adminClient.query<
  832. UpdateProductVariants.Mutation,
  833. UpdateProductVariants.Variables
  834. >(UPDATE_PRODUCT_VARIANTS, {
  835. input: [
  836. {
  837. id: firstVariant.id,
  838. assetIds: ['T_4', 'T_3'],
  839. featuredAssetId: 'T_4',
  840. },
  841. ],
  842. });
  843. const updatedVariant = result.updateProductVariants[0];
  844. if (!updatedVariant) {
  845. fail('no updated variant returned.');
  846. return;
  847. }
  848. expect(updatedVariant.assets.map(a => a.id)).toEqual(['T_4', 'T_3']);
  849. expect(updatedVariant.featuredAsset!.id).toBe('T_4');
  850. });
  851. it('updateProductVariants updates taxCategory and priceBeforeTax', async () => {
  852. const firstVariant = variants[0];
  853. const result = await adminClient.query<
  854. UpdateProductVariants.Mutation,
  855. UpdateProductVariants.Variables
  856. >(UPDATE_PRODUCT_VARIANTS, {
  857. input: [
  858. {
  859. id: firstVariant.id,
  860. price: 105,
  861. taxCategoryId: 'T_2',
  862. },
  863. ],
  864. });
  865. const updatedVariant = result.updateProductVariants[0];
  866. if (!updatedVariant) {
  867. fail('no updated variant returned.');
  868. return;
  869. }
  870. expect(updatedVariant.price).toBe(105);
  871. expect(updatedVariant.taxCategory.id).toBe('T_2');
  872. });
  873. it('updateProductVariants updates facetValues', async () => {
  874. const firstVariant = variants[0];
  875. const result = await adminClient.query<
  876. UpdateProductVariants.Mutation,
  877. UpdateProductVariants.Variables
  878. >(UPDATE_PRODUCT_VARIANTS, {
  879. input: [
  880. {
  881. id: firstVariant.id,
  882. facetValueIds: ['T_1'],
  883. },
  884. ],
  885. });
  886. const updatedVariant = result.updateProductVariants[0];
  887. if (!updatedVariant) {
  888. fail('no updated variant returned.');
  889. return;
  890. }
  891. expect(updatedVariant.facetValues.length).toBe(1);
  892. expect(updatedVariant.facetValues[0].id).toBe('T_1');
  893. });
  894. it(
  895. 'updateProductVariants throws with an invalid variant id',
  896. assertThrowsWithMessage(
  897. () =>
  898. adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  899. UPDATE_PRODUCT_VARIANTS,
  900. {
  901. input: [
  902. {
  903. id: 'T_999',
  904. translations: variants[0].translations,
  905. sku: 'ABC',
  906. price: 432,
  907. },
  908. ],
  909. },
  910. ),
  911. `No ProductVariant with the id '999' could be found`,
  912. ),
  913. );
  914. it('deleteProductVariant', async () => {
  915. const result1 = await adminClient.query<
  916. GetProductWithVariants.Query,
  917. GetProductWithVariants.Variables
  918. >(GET_PRODUCT_WITH_VARIANTS, {
  919. id: newProduct.id,
  920. });
  921. const sortedVariantIds = result1.product!.variants.map(v => v.id).sort();
  922. expect(sortedVariantIds).toEqual(['T_35', 'T_36', 'T_37']);
  923. const { deleteProductVariant } = await adminClient.query<
  924. DeleteProductVariant.Mutation,
  925. DeleteProductVariant.Variables
  926. >(DELETE_PRODUCT_VARIANT, {
  927. id: sortedVariantIds[0],
  928. });
  929. expect(deleteProductVariant.result).toBe(DeletionResult.DELETED);
  930. const result2 = await adminClient.query<
  931. GetProductWithVariants.Query,
  932. GetProductWithVariants.Variables
  933. >(GET_PRODUCT_WITH_VARIANTS, {
  934. id: newProduct.id,
  935. });
  936. expect(result2.product!.variants.map(v => v.id).sort()).toEqual(['T_36', 'T_37']);
  937. });
  938. });
  939. });
  940. describe('deletion', () => {
  941. let allProducts: GetProductList.Items[];
  942. let productToDelete: GetProductList.Items;
  943. beforeAll(async () => {
  944. const result = await adminClient.query<GetProductList.Query, GetProductList.Variables>(
  945. GET_PRODUCT_LIST,
  946. {
  947. options: {
  948. sort: {
  949. id: SortOrder.ASC,
  950. },
  951. },
  952. },
  953. );
  954. allProducts = result.products.items;
  955. });
  956. it('deletes a product', async () => {
  957. productToDelete = allProducts[0];
  958. const result = await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(
  959. DELETE_PRODUCT,
  960. { id: productToDelete.id },
  961. );
  962. expect(result.deleteProduct).toEqual({ result: DeletionResult.DELETED });
  963. });
  964. it('cannot get a deleted product', async () => {
  965. const result = await adminClient.query<
  966. GetProductWithVariants.Query,
  967. GetProductWithVariants.Variables
  968. >(GET_PRODUCT_WITH_VARIANTS, {
  969. id: productToDelete.id,
  970. });
  971. expect(result.product).toBe(null);
  972. });
  973. it('deleted product omitted from list', async () => {
  974. const result = await adminClient.query<GetProductList.Query>(GET_PRODUCT_LIST);
  975. expect(result.products.items.length).toBe(allProducts.length - 1);
  976. expect(result.products.items.map(c => c.id).includes(productToDelete.id)).toBe(false);
  977. });
  978. it(
  979. 'updateProduct throws for deleted product',
  980. assertThrowsWithMessage(
  981. () =>
  982. adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  983. input: {
  984. id: productToDelete.id,
  985. facetValueIds: ['T_1'],
  986. },
  987. }),
  988. `No Product with the id '1' could be found`,
  989. ),
  990. );
  991. it(
  992. 'addOptionGroupToProduct throws for deleted product',
  993. assertThrowsWithMessage(
  994. () =>
  995. adminClient.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
  996. ADD_OPTION_GROUP_TO_PRODUCT,
  997. {
  998. optionGroupId: 'T_1',
  999. productId: productToDelete.id,
  1000. },
  1001. ),
  1002. `No Product with the id '1' could be found`,
  1003. ),
  1004. );
  1005. it(
  1006. 'removeOptionGroupToProduct throws for deleted product',
  1007. assertThrowsWithMessage(
  1008. () =>
  1009. adminClient.query<
  1010. RemoveOptionGroupFromProduct.Mutation,
  1011. RemoveOptionGroupFromProduct.Variables
  1012. >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
  1013. optionGroupId: 'T_1',
  1014. productId: productToDelete.id,
  1015. }),
  1016. `No Product with the id '1' could be found`,
  1017. ),
  1018. );
  1019. });
  1020. });
  1021. export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
  1022. mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
  1023. addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
  1024. id
  1025. optionGroups {
  1026. id
  1027. code
  1028. options {
  1029. id
  1030. code
  1031. }
  1032. }
  1033. }
  1034. }
  1035. `;
  1036. export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
  1037. mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
  1038. removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {
  1039. id
  1040. optionGroups {
  1041. id
  1042. code
  1043. options {
  1044. id
  1045. code
  1046. }
  1047. }
  1048. }
  1049. }
  1050. `;
  1051. export const GET_OPTION_GROUP = gql`
  1052. query GetOptionGroup($id: ID!) {
  1053. productOptionGroup(id: $id) {
  1054. id
  1055. code
  1056. options {
  1057. id
  1058. code
  1059. }
  1060. }
  1061. }
  1062. `;