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

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