elasticsearch-plugin.e2e-spec.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  1. /* tslint:disable:no-non-null-assertion no-console */
  2. import { Client } from '@elastic/elasticsearch';
  3. import { SortOrder } from '@vendure/common/lib/generated-types';
  4. import { pick } from '@vendure/common/lib/pick';
  5. import {
  6. DefaultJobQueuePlugin,
  7. DefaultLogger,
  8. facetValueCollectionFilter,
  9. Logger,
  10. LogLevel,
  11. mergeConfig,
  12. } from '@vendure/core';
  13. import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, SimpleGraphQLClient } from '@vendure/testing';
  14. import gql from 'graphql-tag';
  15. import path from 'path';
  16. import { initialData } from '../../../e2e-common/e2e-initial-data';
  17. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  18. import {
  19. AssignProductsToChannel,
  20. AssignProductVariantsToChannel,
  21. ChannelFragment,
  22. CreateChannel,
  23. CreateCollection,
  24. CreateFacet,
  25. CurrencyCode,
  26. DeleteAsset,
  27. DeleteProduct,
  28. DeleteProductVariant,
  29. LanguageCode,
  30. RemoveProductsFromChannel,
  31. RemoveProductVariantsFromChannel,
  32. SearchFacetValues,
  33. SearchGetPrices,
  34. SearchInput,
  35. UpdateAsset,
  36. UpdateCollection,
  37. UpdateProduct,
  38. UpdateProductVariants,
  39. UpdateTaxRate,
  40. } from '../../core/e2e/graphql/generated-e2e-admin-types';
  41. import { LogicalOperator, SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
  42. import {
  43. ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
  44. ASSIGN_PRODUCT_TO_CHANNEL,
  45. CREATE_CHANNEL,
  46. CREATE_COLLECTION,
  47. CREATE_FACET,
  48. DELETE_ASSET,
  49. DELETE_PRODUCT,
  50. DELETE_PRODUCT_VARIANT,
  51. REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
  52. REMOVE_PRODUCT_FROM_CHANNEL,
  53. UPDATE_ASSET,
  54. UPDATE_COLLECTION,
  55. UPDATE_PRODUCT,
  56. UPDATE_PRODUCT_VARIANTS,
  57. UPDATE_TAX_RATE,
  58. } from '../../core/e2e/graphql/shared-definitions';
  59. import { SEARCH_PRODUCTS_SHOP } from '../../core/e2e/graphql/shop-definitions';
  60. import { awaitRunningJobs } from '../../core/e2e/utils/await-running-jobs';
  61. import { loggerCtx } from '../src/constants';
  62. import { ElasticsearchPlugin } from '../src/plugin';
  63. import {
  64. doAdminSearchQuery,
  65. dropElasticIndices,
  66. testGroupByProduct,
  67. testMatchCollectionId,
  68. testMatchCollectionSlug,
  69. testMatchFacetIdsAnd,
  70. testMatchFacetIdsOr,
  71. testMatchSearchTerm,
  72. testNoGrouping,
  73. testPriceRanges,
  74. testSinglePrices,
  75. } from './e2e-helpers';
  76. import {
  77. GetJobInfo,
  78. JobState,
  79. Reindex,
  80. SearchProductsAdmin,
  81. } from './graphql/generated-e2e-elasticsearch-plugin-types';
  82. // tslint:disable-next-line:no-var-requires
  83. const { elasticsearchHost, elasticsearchPort } = require('./constants');
  84. /**
  85. * The Elasticsearch tests sometimes take a long time in CI due to limited resources.
  86. * We increase the timeout to 30 seconds to prevent failure due to timeouts.
  87. */
  88. if (process.env.CI) {
  89. jest.setTimeout(10 * 3000);
  90. }
  91. const INDEX_PREFIX = 'e2e-tests';
  92. describe('Elasticsearch plugin', () => {
  93. const { server, adminClient, shopClient } = createTestEnvironment(
  94. mergeConfig(testConfig, {
  95. apiOptions: {
  96. port: 4050,
  97. },
  98. workerOptions: {
  99. options: {
  100. port: 4055,
  101. },
  102. },
  103. logger: new DefaultLogger({ level: LogLevel.Info }),
  104. plugins: [
  105. ElasticsearchPlugin.init({
  106. indexPrefix: INDEX_PREFIX,
  107. port: elasticsearchPort,
  108. host: elasticsearchHost,
  109. }),
  110. DefaultJobQueuePlugin,
  111. ],
  112. }),
  113. );
  114. beforeAll(async () => {
  115. await dropElasticIndices(INDEX_PREFIX);
  116. await server.init({
  117. initialData,
  118. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  119. customerCount: 1,
  120. });
  121. await adminClient.asSuperAdmin();
  122. await adminClient.query(REINDEX);
  123. await awaitRunningJobs(adminClient);
  124. }, TEST_SETUP_TIMEOUT_MS);
  125. afterAll(async () => {
  126. await server.destroy();
  127. });
  128. describe('shop api', () => {
  129. it('group by product', () => testGroupByProduct(shopClient));
  130. it('no grouping', () => testNoGrouping(shopClient));
  131. it('matches search term', () => testMatchSearchTerm(shopClient));
  132. it('matches by facetValueId with AND operator', () => testMatchFacetIdsAnd(shopClient));
  133. it('matches by facetValueId with OR operator', () => testMatchFacetIdsOr(shopClient));
  134. it('matches by collectionId', () => testMatchCollectionId(shopClient));
  135. it('matches by collectionSlug', () => testMatchCollectionSlug(shopClient));
  136. it('single prices', () => testSinglePrices(shopClient));
  137. it('price ranges', () => testPriceRanges(shopClient));
  138. it('returns correct facetValues when not grouped by product', async () => {
  139. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  140. SEARCH_GET_FACET_VALUES,
  141. {
  142. input: {
  143. groupByProduct: false,
  144. },
  145. },
  146. );
  147. expect(result.search.facetValues).toEqual([
  148. { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
  149. { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
  150. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  151. { count: 10, facetValue: { id: 'T_4', name: 'sports equipment' } },
  152. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  153. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  154. ]);
  155. });
  156. it('returns correct facetValues when grouped by product', async () => {
  157. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  158. SEARCH_GET_FACET_VALUES,
  159. {
  160. input: {
  161. groupByProduct: true,
  162. },
  163. },
  164. );
  165. expect(result.search.facetValues).toEqual([
  166. { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
  167. { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
  168. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  169. { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
  170. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  171. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  172. ]);
  173. });
  174. it('omits facetValues of private facets', async () => {
  175. const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
  176. CREATE_FACET,
  177. {
  178. input: {
  179. code: 'profit-margin',
  180. isPrivate: true,
  181. translations: [{ languageCode: LanguageCode.en, name: 'Profit Margin' }],
  182. values: [
  183. {
  184. code: 'massive',
  185. translations: [{ languageCode: LanguageCode.en, name: 'massive' }],
  186. },
  187. ],
  188. },
  189. },
  190. );
  191. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  192. input: {
  193. id: 'T_2',
  194. // T_1 & T_2 are the existing facetValues (electronics & photo)
  195. facetValueIds: ['T_1', 'T_2', createFacet.values[0].id],
  196. },
  197. });
  198. await awaitRunningJobs(adminClient);
  199. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  200. SEARCH_GET_FACET_VALUES,
  201. {
  202. input: {
  203. groupByProduct: true,
  204. },
  205. },
  206. );
  207. expect(result.search.facetValues).toEqual([
  208. { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
  209. { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
  210. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  211. { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
  212. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  213. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  214. ]);
  215. });
  216. it('encodes the productId and productVariantId', async () => {
  217. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  218. SEARCH_PRODUCTS_SHOP,
  219. {
  220. input: {
  221. groupByProduct: false,
  222. take: 1,
  223. },
  224. },
  225. );
  226. expect(pick(result.search.items[0], ['productId', 'productVariantId'])).toEqual({
  227. productId: 'T_1',
  228. productVariantId: 'T_1',
  229. });
  230. });
  231. it('omits results for disabled ProductVariants', async () => {
  232. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  233. UPDATE_PRODUCT_VARIANTS,
  234. {
  235. input: [{ id: 'T_3', enabled: false }],
  236. },
  237. );
  238. await awaitRunningJobs(adminClient);
  239. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  240. SEARCH_PRODUCTS_SHOP,
  241. {
  242. input: {
  243. groupByProduct: false,
  244. take: 3,
  245. },
  246. },
  247. );
  248. expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
  249. });
  250. it('encodes collectionIds', async () => {
  251. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  252. SEARCH_PRODUCTS_SHOP,
  253. {
  254. input: {
  255. groupByProduct: false,
  256. term: 'cactus',
  257. take: 1,
  258. },
  259. },
  260. );
  261. expect(result.search.items[0].collectionIds).toEqual(['T_2']);
  262. });
  263. });
  264. describe('admin api', () => {
  265. it('group by product', () => testGroupByProduct(adminClient));
  266. it('no grouping', () => testNoGrouping(adminClient));
  267. it('matches search term', () => testMatchSearchTerm(adminClient));
  268. it('matches by facetValueId with AND operator', () => testMatchFacetIdsAnd(adminClient));
  269. it('matches by facetValueId with OR operator', () => testMatchFacetIdsOr(adminClient));
  270. it('matches by collectionId', () => testMatchCollectionId(adminClient));
  271. it('matches by collectionSlug', () => testMatchCollectionSlug(adminClient));
  272. it('single prices', () => testSinglePrices(adminClient));
  273. it('price ranges', () => testPriceRanges(adminClient));
  274. describe('updating the index', () => {
  275. it('updates index when ProductVariants are changed', async () => {
  276. await awaitRunningJobs(adminClient);
  277. const { search } = await doAdminSearchQuery(adminClient, {
  278. term: 'drive',
  279. groupByProduct: false,
  280. });
  281. expect(search.items.map(i => i.sku)).toEqual([
  282. 'IHD455T1',
  283. 'IHD455T2',
  284. 'IHD455T3',
  285. 'IHD455T4',
  286. 'IHD455T6',
  287. ]);
  288. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  289. UPDATE_PRODUCT_VARIANTS,
  290. {
  291. input: search.items.map(i => ({
  292. id: i.productVariantId,
  293. sku: i.sku + '_updated',
  294. })),
  295. },
  296. );
  297. await awaitRunningJobs(adminClient);
  298. const { search: search2 } = await doAdminSearchQuery(adminClient, {
  299. term: 'drive',
  300. groupByProduct: false,
  301. });
  302. expect(search2.items.map(i => i.sku)).toEqual([
  303. 'IHD455T1_updated',
  304. 'IHD455T2_updated',
  305. 'IHD455T3_updated',
  306. 'IHD455T4_updated',
  307. 'IHD455T6_updated',
  308. ]);
  309. });
  310. it('updates index when ProductVariants are deleted', async () => {
  311. await awaitRunningJobs(adminClient);
  312. const { search } = await doAdminSearchQuery(adminClient, {
  313. term: 'drive',
  314. groupByProduct: false,
  315. });
  316. await adminClient.query<DeleteProductVariant.Mutation, DeleteProductVariant.Variables>(
  317. DELETE_PRODUCT_VARIANT,
  318. {
  319. id: search.items[0].productVariantId,
  320. },
  321. );
  322. await awaitRunningJobs(adminClient);
  323. const { search: search2 } = await doAdminSearchQuery(adminClient, {
  324. term: 'drive',
  325. groupByProduct: false,
  326. });
  327. expect(search2.items.map(i => i.sku).sort()).toEqual([
  328. 'IHD455T2_updated',
  329. 'IHD455T3_updated',
  330. 'IHD455T4_updated',
  331. 'IHD455T6_updated',
  332. ]);
  333. });
  334. it('updates index when a Product is changed', async () => {
  335. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  336. input: {
  337. id: 'T_1',
  338. facetValueIds: [],
  339. },
  340. });
  341. await awaitRunningJobs(adminClient);
  342. const result = await doAdminSearchQuery(adminClient, {
  343. facetValueIds: ['T_2'],
  344. groupByProduct: true,
  345. });
  346. expect(result.search.items.map(i => i.productName).sort()).toEqual([
  347. 'Clacky Keyboard',
  348. 'Curvy Monitor',
  349. 'Gaming PC',
  350. 'Hard Drive',
  351. 'USB Cable',
  352. ]);
  353. });
  354. it('updates index when a Product is deleted', async () => {
  355. const { search } = await doAdminSearchQuery(adminClient, {
  356. facetValueIds: ['T_2'],
  357. groupByProduct: true,
  358. });
  359. expect(search.items.map(i => i.productId).sort()).toEqual([
  360. 'T_2',
  361. 'T_3',
  362. 'T_4',
  363. 'T_5',
  364. 'T_6',
  365. ]);
  366. await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
  367. id: 'T_5',
  368. });
  369. await awaitRunningJobs(adminClient);
  370. const { search: search2 } = await doAdminSearchQuery(adminClient, {
  371. facetValueIds: ['T_2'],
  372. groupByProduct: true,
  373. });
  374. expect(search2.items.map(i => i.productId).sort()).toEqual(['T_2', 'T_3', 'T_4', 'T_6']);
  375. });
  376. it('updates index when a Collection is changed', async () => {
  377. await adminClient.query<UpdateCollection.Mutation, UpdateCollection.Variables>(
  378. UPDATE_COLLECTION,
  379. {
  380. input: {
  381. id: 'T_2',
  382. filters: [
  383. {
  384. code: facetValueCollectionFilter.code,
  385. arguments: [
  386. {
  387. name: 'facetValueIds',
  388. value: `["T_4"]`,
  389. },
  390. {
  391. name: 'containsAny',
  392. value: `false`,
  393. },
  394. ],
  395. },
  396. ],
  397. },
  398. },
  399. );
  400. await awaitRunningJobs(adminClient);
  401. // add an additional check for the collection filters to update
  402. await awaitRunningJobs(adminClient);
  403. const result1 = await doAdminSearchQuery(adminClient, {
  404. collectionId: 'T_2',
  405. groupByProduct: true,
  406. });
  407. expect(result1.search.items.map(i => i.productName)).toEqual([
  408. 'Road Bike',
  409. 'Skipping Rope',
  410. 'Boxing Gloves',
  411. 'Tent',
  412. 'Cruiser Skateboard',
  413. 'Football',
  414. 'Running Shoe',
  415. ]);
  416. const result2 = await doAdminSearchQuery(adminClient, {
  417. collectionSlug: 'plants',
  418. groupByProduct: true,
  419. });
  420. expect(result2.search.items.map(i => i.productName)).toEqual([
  421. 'Road Bike',
  422. 'Skipping Rope',
  423. 'Boxing Gloves',
  424. 'Tent',
  425. 'Cruiser Skateboard',
  426. 'Football',
  427. 'Running Shoe',
  428. ]);
  429. });
  430. it('updates index when a Collection created', async () => {
  431. const { createCollection } = await adminClient.query<
  432. CreateCollection.Mutation,
  433. CreateCollection.Variables
  434. >(CREATE_COLLECTION, {
  435. input: {
  436. translations: [
  437. {
  438. languageCode: LanguageCode.en,
  439. name: 'Photo',
  440. description: '',
  441. slug: 'photo',
  442. },
  443. ],
  444. filters: [
  445. {
  446. code: facetValueCollectionFilter.code,
  447. arguments: [
  448. {
  449. name: 'facetValueIds',
  450. value: `["T_3"]`,
  451. },
  452. {
  453. name: 'containsAny',
  454. value: `false`,
  455. },
  456. ],
  457. },
  458. ],
  459. },
  460. });
  461. await awaitRunningJobs(adminClient);
  462. // add an additional check for the collection filters to update
  463. await awaitRunningJobs(adminClient);
  464. const result = await doAdminSearchQuery(adminClient, {
  465. collectionId: createCollection.id,
  466. groupByProduct: true,
  467. });
  468. expect(result.search.items.map(i => i.productName)).toEqual([
  469. 'Instant Camera',
  470. 'Camera Lens',
  471. 'Tripod',
  472. 'SLR Camera',
  473. ]);
  474. });
  475. it('updates index when a taxRate is changed', async () => {
  476. await adminClient.query<UpdateTaxRate.Mutation, UpdateTaxRate.Variables>(UPDATE_TAX_RATE, {
  477. input: {
  478. // Default Channel's defaultTaxZone is Europe (id 2) and the id of the standard TaxRate
  479. // to Europe is 2.
  480. id: 'T_2',
  481. value: 50,
  482. },
  483. });
  484. await awaitRunningJobs(adminClient);
  485. const result = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
  486. SEARCH_GET_PRICES,
  487. {
  488. input: {
  489. groupByProduct: true,
  490. term: 'laptop',
  491. } as SearchInput,
  492. },
  493. );
  494. expect(result.search.items).toEqual([
  495. {
  496. price: { min: 129900, max: 229900 },
  497. priceWithTax: { min: 194850, max: 344850 },
  498. },
  499. ]);
  500. });
  501. describe('asset changes', () => {
  502. function searchForLaptop() {
  503. return doAdminSearchQuery(adminClient, {
  504. term: 'laptop',
  505. groupByProduct: true,
  506. take: 1,
  507. sort: {
  508. name: SortOrder.ASC,
  509. },
  510. });
  511. }
  512. it('updates index when asset focalPoint is changed', async () => {
  513. const { search: search1 } = await searchForLaptop();
  514. expect(search1.items[0].productAsset!.id).toBe('T_1');
  515. expect(search1.items[0].productAsset!.focalPoint).toBeNull();
  516. await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(UPDATE_ASSET, {
  517. input: {
  518. id: 'T_1',
  519. focalPoint: {
  520. x: 0.42,
  521. y: 0.42,
  522. },
  523. },
  524. });
  525. await awaitRunningJobs(adminClient);
  526. const { search: search2 } = await searchForLaptop();
  527. expect(search2.items[0].productAsset!.id).toBe('T_1');
  528. expect(search2.items[0].productAsset!.focalPoint).toEqual({ x: 0.42, y: 0.42 });
  529. });
  530. it('updates index when asset deleted', async () => {
  531. const { search: search1 } = await searchForLaptop();
  532. const assetId = search1.items[0].productAsset?.id;
  533. expect(assetId).toBeTruthy();
  534. await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(DELETE_ASSET, {
  535. id: assetId!,
  536. force: true,
  537. });
  538. await awaitRunningJobs(adminClient);
  539. const { search: search2 } = await searchForLaptop();
  540. expect(search2.items[0].productAsset).toBeNull();
  541. });
  542. });
  543. it('does not include deleted ProductVariants in index', async () => {
  544. const { search: s1 } = await doAdminSearchQuery(adminClient, {
  545. term: 'hard drive',
  546. groupByProduct: false,
  547. });
  548. const variantToDelete = s1.items.find(i => i.sku === 'IHD455T2_updated')!;
  549. const { deleteProductVariant } = await adminClient.query<
  550. DeleteProductVariant.Mutation,
  551. DeleteProductVariant.Variables
  552. >(DELETE_PRODUCT_VARIANT, { id: variantToDelete.productVariantId });
  553. await awaitRunningJobs(adminClient);
  554. const { search } = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
  555. SEARCH_GET_PRICES,
  556. { input: { term: 'hard drive', groupByProduct: true } },
  557. );
  558. expect(search.items[0].price).toEqual({
  559. min: 7896,
  560. max: 13435,
  561. });
  562. });
  563. it('returns disabled field when not grouped', async () => {
  564. const result = await doAdminSearchQuery(adminClient, {
  565. groupByProduct: false,
  566. term: 'laptop',
  567. });
  568. expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
  569. { productVariantId: 'T_1', enabled: true },
  570. { productVariantId: 'T_2', enabled: true },
  571. { productVariantId: 'T_3', enabled: false },
  572. { productVariantId: 'T_4', enabled: true },
  573. ]);
  574. });
  575. it('when grouped, disabled is false if at least one variant is enabled', async () => {
  576. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  577. UPDATE_PRODUCT_VARIANTS,
  578. {
  579. input: [
  580. { id: 'T_1', enabled: false },
  581. { id: 'T_2', enabled: false },
  582. ],
  583. },
  584. );
  585. await awaitRunningJobs(adminClient);
  586. const result = await doAdminSearchQuery(adminClient, {
  587. groupByProduct: true,
  588. term: 'laptop',
  589. });
  590. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  591. { productId: 'T_1', enabled: true },
  592. ]);
  593. });
  594. it('when grouped, disabled is true if all variants are disabled', async () => {
  595. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  596. UPDATE_PRODUCT_VARIANTS,
  597. {
  598. input: [{ id: 'T_4', enabled: false }],
  599. },
  600. );
  601. await awaitRunningJobs(adminClient);
  602. const result = await doAdminSearchQuery(adminClient, {
  603. groupByProduct: true,
  604. take: 3,
  605. term: 'laptop',
  606. });
  607. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  608. { productId: 'T_1', enabled: false },
  609. ]);
  610. });
  611. it('when grouped, disabled is true product is disabled', async () => {
  612. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  613. input: {
  614. id: 'T_3',
  615. enabled: false,
  616. },
  617. });
  618. await awaitRunningJobs(adminClient);
  619. const result = await doAdminSearchQuery(adminClient, {
  620. groupByProduct: true,
  621. term: 'gaming',
  622. });
  623. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  624. { productId: 'T_3', enabled: false },
  625. ]);
  626. });
  627. // https://github.com/vendure-ecommerce/vendure/issues/295
  628. it('enabled status survives reindex', async () => {
  629. await adminClient.query<Reindex.Mutation>(REINDEX);
  630. await awaitRunningJobs(adminClient);
  631. const result = await doAdminSearchQuery(adminClient, { groupByProduct: true, take: 3 });
  632. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  633. { productId: 'T_1', enabled: false },
  634. { productId: 'T_2', enabled: true },
  635. { productId: 'T_3', enabled: false },
  636. ]);
  637. });
  638. });
  639. describe('channel handling', () => {
  640. const SECOND_CHANNEL_TOKEN = 'second-channel-token';
  641. let secondChannel: ChannelFragment;
  642. beforeAll(async () => {
  643. const { createChannel } = await adminClient.query<
  644. CreateChannel.Mutation,
  645. CreateChannel.Variables
  646. >(CREATE_CHANNEL, {
  647. input: {
  648. code: 'second-channel',
  649. token: SECOND_CHANNEL_TOKEN,
  650. defaultLanguageCode: LanguageCode.en,
  651. currencyCode: CurrencyCode.GBP,
  652. pricesIncludeTax: true,
  653. defaultTaxZoneId: 'T_2',
  654. defaultShippingZoneId: 'T_1',
  655. },
  656. });
  657. secondChannel = createChannel as ChannelFragment;
  658. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  659. await adminClient.query<Reindex.Mutation>(REINDEX);
  660. await awaitRunningJobs(adminClient);
  661. });
  662. it('new channel is initially empty', async () => {
  663. const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
  664. groupByProduct: true,
  665. });
  666. const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
  667. groupByProduct: false,
  668. });
  669. expect(searchGrouped.totalItems).toEqual(0);
  670. expect(searchUngrouped.totalItems).toEqual(0);
  671. });
  672. it('adding product to channel', async () => {
  673. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  674. await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
  675. ASSIGN_PRODUCT_TO_CHANNEL,
  676. {
  677. input: { channelId: secondChannel.id, productIds: ['T_1', 'T_2'] },
  678. },
  679. );
  680. await awaitRunningJobs(adminClient);
  681. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  682. const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
  683. expect(search.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_2']);
  684. });
  685. it('removing product from channel', async () => {
  686. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  687. const { removeProductsFromChannel } = await adminClient.query<
  688. RemoveProductsFromChannel.Mutation,
  689. RemoveProductsFromChannel.Variables
  690. >(REMOVE_PRODUCT_FROM_CHANNEL, {
  691. input: {
  692. productIds: ['T_2'],
  693. channelId: secondChannel.id,
  694. },
  695. });
  696. await awaitRunningJobs(adminClient);
  697. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  698. const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
  699. expect(search.items.map(i => i.productId)).toEqual(['T_1']);
  700. });
  701. it('reindexes in channel', async () => {
  702. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  703. const { reindex } = await adminClient.query<Reindex.Mutation>(REINDEX);
  704. await awaitRunningJobs(adminClient);
  705. const { job } = await adminClient.query<GetJobInfo.Query, GetJobInfo.Variables>(
  706. GET_JOB_INFO,
  707. { id: reindex.id },
  708. );
  709. expect(job!.state).toBe(JobState.COMPLETED);
  710. const { search } = await doAdminSearchQuery(adminClient, { groupByProduct: true });
  711. expect(search.items.map(i => i.productId).sort()).toEqual(['T_1']);
  712. });
  713. it('adding product variant to channel', async () => {
  714. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  715. await adminClient.query<
  716. AssignProductVariantsToChannel.Mutation,
  717. AssignProductVariantsToChannel.Variables
  718. >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
  719. input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
  720. });
  721. await awaitRunningJobs(adminClient);
  722. // Updating of index sometimes flaky (postgres), so add a delay
  723. await new Promise(resolve => setTimeout(resolve, 500));
  724. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  725. const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
  726. groupByProduct: true,
  727. });
  728. expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3', 'T_4']);
  729. const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
  730. groupByProduct: false,
  731. });
  732. expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([
  733. 'T_1',
  734. 'T_10',
  735. 'T_15',
  736. 'T_2',
  737. 'T_3',
  738. 'T_4',
  739. ]);
  740. });
  741. it('removing product variant from channel', async () => {
  742. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  743. await adminClient.query<
  744. RemoveProductVariantsFromChannel.Mutation,
  745. RemoveProductVariantsFromChannel.Variables
  746. >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
  747. input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] },
  748. });
  749. await awaitRunningJobs(adminClient);
  750. // Updating of index sometimes flaky (postgres), so add a delay
  751. await new Promise(resolve => setTimeout(resolve, 500));
  752. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  753. const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
  754. groupByProduct: true,
  755. });
  756. expect(searchGrouped.items.map(i => i.productId).sort()).toEqual(['T_1', 'T_3']);
  757. const { search: searchUngrouped } = await doAdminSearchQuery(adminClient, {
  758. groupByProduct: false,
  759. });
  760. expect(searchUngrouped.items.map(i => i.productVariantId).sort()).toEqual([
  761. 'T_10',
  762. 'T_2',
  763. 'T_3',
  764. 'T_4',
  765. ]);
  766. });
  767. it('updating product affects current channel', async () => {
  768. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  769. const { updateProduct } = await adminClient.query<
  770. UpdateProduct.Mutation,
  771. UpdateProduct.Variables
  772. >(UPDATE_PRODUCT, {
  773. input: {
  774. id: 'T_3',
  775. enabled: true,
  776. translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
  777. },
  778. });
  779. await awaitRunningJobs(adminClient);
  780. const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
  781. groupByProduct: true,
  782. term: 'xyz',
  783. });
  784. expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
  785. });
  786. it('updating product affects other channels', async () => {
  787. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  788. const { search: searchGrouped } = await doAdminSearchQuery(adminClient, {
  789. groupByProduct: true,
  790. term: 'xyz',
  791. });
  792. expect(searchGrouped.items.map(i => i.productName)).toEqual(['xyz']);
  793. });
  794. });
  795. describe('multiple language handling', () => {
  796. function searchInLanguage(languageCode: LanguageCode, groupByProduct: boolean) {
  797. return adminClient.query<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(
  798. SEARCH_PRODUCTS,
  799. {
  800. input: {
  801. take: 1,
  802. term: 'laptop',
  803. groupByProduct,
  804. },
  805. },
  806. {
  807. languageCode,
  808. },
  809. );
  810. }
  811. beforeAll(async () => {
  812. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  813. const { updateProduct } = await adminClient.query<
  814. UpdateProduct.Mutation,
  815. UpdateProduct.Variables
  816. >(UPDATE_PRODUCT, {
  817. input: {
  818. id: 'T_1',
  819. translations: [
  820. {
  821. languageCode: LanguageCode.de,
  822. name: 'laptop name de',
  823. slug: 'laptop-slug-de',
  824. description: 'laptop description de',
  825. },
  826. {
  827. languageCode: LanguageCode.zh,
  828. name: 'laptop name zh',
  829. slug: 'laptop-slug-zh',
  830. description: 'laptop description zh',
  831. },
  832. ],
  833. },
  834. });
  835. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  836. UPDATE_PRODUCT_VARIANTS,
  837. {
  838. input: [
  839. {
  840. id: updateProduct.variants[0].id,
  841. translations: [
  842. {
  843. languageCode: LanguageCode.fr,
  844. name: 'laptop variant fr',
  845. },
  846. ],
  847. },
  848. ],
  849. },
  850. );
  851. await awaitRunningJobs(adminClient);
  852. });
  853. it('indexes product-level languages', async () => {
  854. const { search: search1 } = await searchInLanguage(LanguageCode.de, true);
  855. expect(search1.items[0].productName).toBe('laptop name de');
  856. expect(search1.items[0].slug).toBe('laptop-slug-de');
  857. expect(search1.items[0].description).toBe('laptop description de');
  858. const { search: search2 } = await searchInLanguage(LanguageCode.zh, true);
  859. expect(search2.items[0].productName).toBe('laptop name zh');
  860. expect(search2.items[0].slug).toBe('laptop-slug-zh');
  861. expect(search2.items[0].description).toBe('laptop description zh');
  862. });
  863. it('indexes product variant-level languages', async () => {
  864. const { search: search1 } = await searchInLanguage(LanguageCode.fr, false);
  865. expect(search1.items[0].productName).toBe('Laptop');
  866. expect(search1.items[0].productVariantName).toBe('laptop variant fr');
  867. });
  868. });
  869. });
  870. });
  871. export const SEARCH_PRODUCTS = gql`
  872. query SearchProductsAdmin($input: SearchInput!) {
  873. search(input: $input) {
  874. totalItems
  875. items {
  876. enabled
  877. productId
  878. productName
  879. slug
  880. description
  881. productAsset {
  882. id
  883. preview
  884. focalPoint {
  885. x
  886. y
  887. }
  888. }
  889. productPreview
  890. productVariantId
  891. productVariantName
  892. productVariantAsset {
  893. id
  894. preview
  895. focalPoint {
  896. x
  897. y
  898. }
  899. }
  900. productVariantPreview
  901. sku
  902. }
  903. }
  904. }
  905. `;
  906. export const SEARCH_GET_FACET_VALUES = gql`
  907. query SearchFacetValues($input: SearchInput!) {
  908. search(input: $input) {
  909. totalItems
  910. facetValues {
  911. count
  912. facetValue {
  913. id
  914. name
  915. }
  916. }
  917. }
  918. }
  919. `;
  920. export const SEARCH_GET_PRICES = gql`
  921. query SearchGetPrices($input: SearchInput!) {
  922. search(input: $input) {
  923. items {
  924. price {
  925. ... on PriceRange {
  926. min
  927. max
  928. }
  929. ... on SinglePrice {
  930. value
  931. }
  932. }
  933. priceWithTax {
  934. ... on PriceRange {
  935. min
  936. max
  937. }
  938. ... on SinglePrice {
  939. value
  940. }
  941. }
  942. }
  943. }
  944. }
  945. `;
  946. const REINDEX = gql`
  947. mutation Reindex {
  948. reindex {
  949. id
  950. queueName
  951. state
  952. progress
  953. duration
  954. result
  955. }
  956. }
  957. `;
  958. const GET_JOB_INFO = gql`
  959. query GetJobInfo($id: ID!) {
  960. job(jobId: $id) {
  961. id
  962. queueName
  963. state
  964. progress
  965. duration
  966. result
  967. }
  968. }
  969. `;