default-search-plugin.e2e-spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import { pick } from '@vendure/common/lib/pick';
  2. import gql from 'graphql-tag';
  3. import path from 'path';
  4. import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
  5. import { facetValueCollectionFilter } from '../src/config/collection/default-collection-filters';
  6. import { DefaultSearchPlugin } from '../src/plugin/default-search-plugin/default-search-plugin';
  7. import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
  8. import {
  9. CreateCollection,
  10. CreateFacet,
  11. GetRunningJobs,
  12. JobState,
  13. LanguageCode,
  14. SearchFacetValues,
  15. SearchGetPrices,
  16. SearchInput,
  17. UpdateCollection,
  18. UpdateProduct,
  19. UpdateProductVariants,
  20. UpdateTaxRate,
  21. } from './graphql/generated-e2e-admin-types';
  22. import { SearchProductsShop } from './graphql/generated-e2e-shop-types';
  23. import {
  24. CREATE_COLLECTION,
  25. CREATE_FACET,
  26. UPDATE_COLLECTION,
  27. UPDATE_PRODUCT,
  28. UPDATE_PRODUCT_VARIANTS,
  29. UPDATE_TAX_RATE,
  30. } from './graphql/shared-definitions';
  31. import { SEARCH_PRODUCTS_SHOP } from './graphql/shop-definitions';
  32. import { TestAdminClient, TestShopClient } from './test-client';
  33. import { TestServer } from './test-server';
  34. describe('Default search plugin', () => {
  35. const adminClient = new TestAdminClient();
  36. const shopClient = new TestShopClient();
  37. const server = new TestServer();
  38. beforeAll(async () => {
  39. const token = await server.init(
  40. {
  41. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  42. customerCount: 1,
  43. },
  44. {
  45. plugins: [DefaultSearchPlugin],
  46. },
  47. );
  48. await adminClient.init();
  49. await shopClient.init();
  50. }, TEST_SETUP_TIMEOUT_MS);
  51. afterAll(async () => {
  52. await server.destroy();
  53. });
  54. async function testGroupByProduct(client: SimpleGraphQLClient) {
  55. const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  56. SEARCH_PRODUCTS_SHOP,
  57. {
  58. input: {
  59. groupByProduct: true,
  60. },
  61. },
  62. );
  63. expect(result.search.totalItems).toBe(20);
  64. }
  65. async function testNoGrouping(client: SimpleGraphQLClient) {
  66. const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  67. SEARCH_PRODUCTS_SHOP,
  68. {
  69. input: {
  70. groupByProduct: false,
  71. },
  72. },
  73. );
  74. expect(result.search.totalItems).toBe(34);
  75. }
  76. async function testMatchSearchTerm(client: SimpleGraphQLClient) {
  77. const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  78. SEARCH_PRODUCTS_SHOP,
  79. {
  80. input: {
  81. term: 'camera',
  82. groupByProduct: true,
  83. },
  84. },
  85. );
  86. expect(result.search.items.map(i => i.productName)).toEqual([
  87. 'Instant Camera',
  88. 'Camera Lens',
  89. 'SLR Camera',
  90. ]);
  91. }
  92. async function testMatchFacetIds(client: SimpleGraphQLClient) {
  93. const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  94. SEARCH_PRODUCTS_SHOP,
  95. {
  96. input: {
  97. facetValueIds: ['T_1', 'T_2'],
  98. groupByProduct: true,
  99. },
  100. },
  101. );
  102. expect(result.search.items.map(i => i.productName)).toEqual([
  103. 'Laptop',
  104. 'Curvy Monitor',
  105. 'Gaming PC',
  106. 'Hard Drive',
  107. 'Clacky Keyboard',
  108. 'USB Cable',
  109. ]);
  110. }
  111. async function testMatchCollectionId(client: SimpleGraphQLClient) {
  112. const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  113. SEARCH_PRODUCTS_SHOP,
  114. {
  115. input: {
  116. collectionId: 'T_2',
  117. groupByProduct: true,
  118. },
  119. },
  120. );
  121. expect(result.search.items.map(i => i.productName)).toEqual([
  122. 'Spiky Cactus',
  123. 'Orchid',
  124. 'Bonsai Tree',
  125. ]);
  126. }
  127. async function testSinglePrices(client: SimpleGraphQLClient) {
  128. const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
  129. SEARCH_GET_PRICES,
  130. {
  131. input: {
  132. groupByProduct: false,
  133. take: 3,
  134. } as SearchInput,
  135. },
  136. );
  137. expect(result.search.items).toEqual([
  138. {
  139. price: { value: 129900 },
  140. priceWithTax: { value: 155880 },
  141. },
  142. {
  143. price: { value: 139900 },
  144. priceWithTax: { value: 167880 },
  145. },
  146. {
  147. price: { value: 219900 },
  148. priceWithTax: { value: 263880 },
  149. },
  150. ]);
  151. }
  152. async function testPriceRanges(client: SimpleGraphQLClient) {
  153. const result = await client.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
  154. SEARCH_GET_PRICES,
  155. {
  156. input: {
  157. groupByProduct: true,
  158. take: 3,
  159. } as SearchInput,
  160. },
  161. );
  162. expect(result.search.items).toEqual([
  163. {
  164. price: { min: 129900, max: 229900 },
  165. priceWithTax: { min: 155880, max: 275880 },
  166. },
  167. {
  168. price: { min: 14374, max: 16994 },
  169. priceWithTax: { min: 17249, max: 20393 },
  170. },
  171. {
  172. price: { min: 93120, max: 109995 },
  173. priceWithTax: { min: 111744, max: 131994 },
  174. },
  175. ]);
  176. }
  177. describe('shop api', () => {
  178. it('group by product', () => testGroupByProduct(shopClient));
  179. it('no grouping', () => testNoGrouping(shopClient));
  180. it('matches search term', () => testMatchSearchTerm(shopClient));
  181. it('matches by facetId', () => testMatchFacetIds(shopClient));
  182. it('matches by collectionId', () => testMatchCollectionId(shopClient));
  183. it('single prices', () => testSinglePrices(shopClient));
  184. it('price ranges', () => testPriceRanges(shopClient));
  185. it('returns correct facetValues when not grouped by product', async () => {
  186. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  187. SEARCH_GET_FACET_VALUES,
  188. {
  189. input: {
  190. groupByProduct: false,
  191. },
  192. },
  193. );
  194. expect(result.search.facetValues).toEqual([
  195. { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
  196. { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
  197. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  198. { count: 10, facetValue: { id: 'T_4', name: 'sports equipment' } },
  199. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  200. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  201. ]);
  202. });
  203. it('returns correct facetValues when grouped by product', async () => {
  204. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  205. SEARCH_GET_FACET_VALUES,
  206. {
  207. input: {
  208. groupByProduct: true,
  209. },
  210. },
  211. );
  212. expect(result.search.facetValues).toEqual([
  213. { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
  214. { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
  215. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  216. { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
  217. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  218. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  219. ]);
  220. });
  221. it('omits facetValues of private facets', async () => {
  222. const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
  223. CREATE_FACET,
  224. {
  225. input: {
  226. code: 'profit-margin',
  227. isPrivate: true,
  228. translations: [{ languageCode: LanguageCode.en, name: 'Profit Margin' }],
  229. values: [
  230. {
  231. code: 'massive',
  232. translations: [{ languageCode: LanguageCode.en, name: 'massive' }],
  233. },
  234. ],
  235. },
  236. },
  237. );
  238. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  239. input: {
  240. id: 'T_2',
  241. // T_1 & T_2 are the existing facetValues (electronics & photo)
  242. facetValueIds: ['T_1', 'T_2', createFacet.values[0].id],
  243. },
  244. });
  245. const result = await shopClient.query<SearchFacetValues.Query, SearchFacetValues.Variables>(
  246. SEARCH_GET_FACET_VALUES,
  247. {
  248. input: {
  249. groupByProduct: true,
  250. },
  251. },
  252. );
  253. expect(result.search.facetValues).toEqual([
  254. { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
  255. { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
  256. { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
  257. { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
  258. { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
  259. { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
  260. ]);
  261. });
  262. it('encodes the productId and productVariantId', async () => {
  263. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  264. SEARCH_PRODUCTS_SHOP,
  265. {
  266. input: {
  267. groupByProduct: false,
  268. take: 1,
  269. },
  270. },
  271. );
  272. expect(pick(result.search.items[0], ['productId', 'productVariantId'])).toEqual({
  273. productId: 'T_1',
  274. productVariantId: 'T_1',
  275. });
  276. });
  277. it('omits results for disabled ProductVariants', async () => {
  278. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  279. UPDATE_PRODUCT_VARIANTS,
  280. {
  281. input: [{ id: 'T_3', enabled: false }],
  282. },
  283. );
  284. await awaitRunningJobs();
  285. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  286. SEARCH_PRODUCTS_SHOP,
  287. {
  288. input: {
  289. groupByProduct: false,
  290. take: 3,
  291. },
  292. },
  293. );
  294. expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
  295. });
  296. it('encodes collectionIds', async () => {
  297. const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  298. SEARCH_PRODUCTS_SHOP,
  299. {
  300. input: {
  301. groupByProduct: false,
  302. term: 'cactus',
  303. take: 1,
  304. },
  305. },
  306. );
  307. expect(result.search.items[0].collectionIds).toEqual(['T_2']);
  308. });
  309. });
  310. describe('admin api', () => {
  311. it('group by product', () => testGroupByProduct(adminClient));
  312. it('no grouping', () => testNoGrouping(adminClient));
  313. it('matches search term', () => testMatchSearchTerm(adminClient));
  314. it('matches by facetId', () => testMatchFacetIds(adminClient));
  315. it('matches by collectionId', () => testMatchCollectionId(adminClient));
  316. it('single prices', () => testSinglePrices(adminClient));
  317. it('price ranges', () => testPriceRanges(adminClient));
  318. it('updates index when a Product is changed', async () => {
  319. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  320. input: {
  321. id: 'T_1',
  322. facetValueIds: [],
  323. },
  324. });
  325. await awaitRunningJobs();
  326. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  327. SEARCH_PRODUCTS,
  328. {
  329. input: {
  330. facetValueIds: ['T_2'],
  331. groupByProduct: true,
  332. },
  333. },
  334. );
  335. expect(result.search.items.map(i => i.productName)).toEqual([
  336. 'Curvy Monitor',
  337. 'Gaming PC',
  338. 'Hard Drive',
  339. 'Clacky Keyboard',
  340. 'USB Cable',
  341. ]);
  342. });
  343. it('updates index when a Collection is changed', async () => {
  344. await adminClient.query<UpdateCollection.Mutation, UpdateCollection.Variables>(
  345. UPDATE_COLLECTION,
  346. {
  347. input: {
  348. id: 'T_2',
  349. filters: [
  350. {
  351. code: facetValueCollectionFilter.code,
  352. arguments: [
  353. {
  354. name: 'facetValueIds',
  355. value: `["T_4"]`,
  356. type: 'facetValueIds',
  357. },
  358. {
  359. name: 'containsAny',
  360. value: `false`,
  361. type: 'boolean',
  362. },
  363. ],
  364. },
  365. ],
  366. },
  367. },
  368. );
  369. await awaitRunningJobs();
  370. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  371. SEARCH_PRODUCTS,
  372. {
  373. input: {
  374. collectionId: 'T_2',
  375. groupByProduct: true,
  376. },
  377. },
  378. );
  379. expect(result.search.items.map(i => i.productName)).toEqual([
  380. 'Road Bike',
  381. 'Skipping Rope',
  382. 'Boxing Gloves',
  383. 'Tent',
  384. 'Cruiser Skateboard',
  385. 'Football',
  386. 'Running Shoe',
  387. ]);
  388. });
  389. it('updates index when a Collection created', async () => {
  390. const { createCollection } = await adminClient.query<
  391. CreateCollection.Mutation,
  392. CreateCollection.Variables
  393. >(CREATE_COLLECTION, {
  394. input: {
  395. translations: [
  396. {
  397. languageCode: LanguageCode.en,
  398. name: 'Photo',
  399. description: '',
  400. },
  401. ],
  402. filters: [
  403. {
  404. code: facetValueCollectionFilter.code,
  405. arguments: [
  406. {
  407. name: 'facetValueIds',
  408. value: `["T_3"]`,
  409. type: 'facetValueIds',
  410. },
  411. {
  412. name: 'containsAny',
  413. value: `false`,
  414. type: 'boolean',
  415. },
  416. ],
  417. },
  418. ],
  419. },
  420. });
  421. await awaitRunningJobs();
  422. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  423. SEARCH_PRODUCTS,
  424. {
  425. input: {
  426. collectionId: createCollection.id,
  427. groupByProduct: true,
  428. },
  429. },
  430. );
  431. expect(result.search.items.map(i => i.productName)).toEqual([
  432. 'Instant Camera',
  433. 'Camera Lens',
  434. 'Tripod',
  435. 'SLR Camera',
  436. ]);
  437. });
  438. it('updates index when a taxRate is changed', async () => {
  439. await adminClient.query<UpdateTaxRate.Mutation, UpdateTaxRate.Variables>(UPDATE_TAX_RATE, {
  440. input: {
  441. // Default Channel's defaultTaxZone is Europe (id 2) and the id of the standard TaxRate
  442. // to Europe is 2.
  443. id: 'T_2',
  444. value: 50,
  445. },
  446. });
  447. await awaitRunningJobs();
  448. const result = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
  449. SEARCH_GET_PRICES,
  450. {
  451. input: {
  452. groupByProduct: true,
  453. term: 'laptop',
  454. } as SearchInput,
  455. },
  456. );
  457. expect(result.search.items).toEqual([
  458. {
  459. price: { min: 129900, max: 229900 },
  460. priceWithTax: { min: 194850, max: 344850 },
  461. },
  462. ]);
  463. });
  464. it('returns disabled field when not grouped', async () => {
  465. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  466. SEARCH_PRODUCTS,
  467. {
  468. input: {
  469. groupByProduct: false,
  470. take: 3,
  471. },
  472. },
  473. );
  474. expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
  475. { productVariantId: 'T_1', enabled: true },
  476. { productVariantId: 'T_2', enabled: true },
  477. { productVariantId: 'T_3', enabled: false },
  478. ]);
  479. });
  480. it('when grouped, disabled is false if at least one variant is enabled', async () => {
  481. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  482. UPDATE_PRODUCT_VARIANTS,
  483. {
  484. input: [{ id: 'T_1', enabled: false }, { id: 'T_2', enabled: false }],
  485. },
  486. );
  487. await awaitRunningJobs();
  488. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  489. SEARCH_PRODUCTS,
  490. {
  491. input: {
  492. groupByProduct: true,
  493. take: 3,
  494. },
  495. },
  496. );
  497. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  498. { productId: 'T_1', enabled: true },
  499. { productId: 'T_2', enabled: true },
  500. { productId: 'T_3', enabled: true },
  501. ]);
  502. });
  503. it('when grouped, disabled is true if all variants are disabled', async () => {
  504. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  505. UPDATE_PRODUCT_VARIANTS,
  506. {
  507. input: [{ id: 'T_4', enabled: false }],
  508. },
  509. );
  510. await awaitRunningJobs();
  511. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  512. SEARCH_PRODUCTS,
  513. {
  514. input: {
  515. groupByProduct: true,
  516. take: 3,
  517. },
  518. },
  519. );
  520. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  521. { productId: 'T_1', enabled: false },
  522. { productId: 'T_2', enabled: true },
  523. { productId: 'T_3', enabled: true },
  524. ]);
  525. });
  526. it('when grouped, disabled is true product is disabled', async () => {
  527. await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
  528. input: {
  529. id: 'T_3',
  530. enabled: false,
  531. },
  532. });
  533. await awaitRunningJobs();
  534. const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
  535. SEARCH_PRODUCTS,
  536. {
  537. input: {
  538. groupByProduct: true,
  539. take: 3,
  540. },
  541. },
  542. );
  543. expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
  544. { productId: 'T_1', enabled: false },
  545. { productId: 'T_2', enabled: true },
  546. { productId: 'T_3', enabled: false },
  547. ]);
  548. });
  549. });
  550. /**
  551. * Since the updates to the search index are performed in the background, we need
  552. * to ensure that any running background jobs are completed before continuing certain
  553. * tests.
  554. */
  555. async function awaitRunningJobs() {
  556. let runningJobs = 0;
  557. do {
  558. const { jobs } = await adminClient.query<GetRunningJobs.Query>(GET_RUNNING_JOBS);
  559. runningJobs = jobs.filter(job => job.state !== JobState.COMPLETED).length;
  560. } while (runningJobs > 0);
  561. }
  562. });
  563. export const GET_RUNNING_JOBS = gql`
  564. query GetRunningJobs {
  565. jobs {
  566. name
  567. state
  568. }
  569. }
  570. `;
  571. export const SEARCH_PRODUCTS = gql`
  572. query SearchProductsAdmin($input: SearchInput!) {
  573. search(input: $input) {
  574. totalItems
  575. items {
  576. enabled
  577. productId
  578. productName
  579. productPreview
  580. productVariantId
  581. productVariantName
  582. productVariantPreview
  583. sku
  584. }
  585. }
  586. }
  587. `;
  588. export const SEARCH_GET_FACET_VALUES = gql`
  589. query SearchFacetValues($input: SearchInput!) {
  590. search(input: $input) {
  591. totalItems
  592. facetValues {
  593. count
  594. facetValue {
  595. id
  596. name
  597. }
  598. }
  599. }
  600. }
  601. `;
  602. export const SEARCH_GET_PRICES = gql`
  603. query SearchGetPrices($input: SearchInput!) {
  604. search(input: $input) {
  605. items {
  606. price {
  607. ... on PriceRange {
  608. min
  609. max
  610. }
  611. ... on SinglePrice {
  612. value
  613. }
  614. }
  615. priceWithTax {
  616. ... on PriceRange {
  617. min
  618. max
  619. }
  620. ... on SinglePrice {
  621. value
  622. }
  623. }
  624. }
  625. }
  626. }
  627. `;