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

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