slug.e2e-spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. import { LanguageCode } from '@vendure/common/lib/generated-types';
  2. import { createTestEnvironment } from '@vendure/testing';
  3. import path from 'path';
  4. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  5. import { initialData } from '../../../e2e-common/e2e-initial-data';
  6. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  7. import { graphql } from './graphql/graphql-admin';
  8. import { createCollectionDocument, createProductDocument } from './graphql/shared-definitions';
  9. describe('Slug generation', () => {
  10. const { server, adminClient } = createTestEnvironment(testConfig());
  11. beforeAll(async () => {
  12. await server.init({
  13. initialData,
  14. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  15. customerCount: 1,
  16. });
  17. await adminClient.asSuperAdmin();
  18. }, TEST_SETUP_TIMEOUT_MS);
  19. afterAll(async () => {
  20. await server.destroy();
  21. });
  22. describe('slugForEntity query', () => {
  23. describe('basic slug generation', () => {
  24. it('generates a simple slug', async () => {
  25. const result = await adminClient.query(slugForEntityDocument, {
  26. input: {
  27. entityName: 'Product',
  28. fieldName: 'slug',
  29. inputValue: 'Test Product',
  30. },
  31. });
  32. expect(result.slugForEntity).toBe('test-product');
  33. });
  34. it('handles multiple spaces', async () => {
  35. const result = await adminClient.query(slugForEntityDocument, {
  36. input: {
  37. entityName: 'Product',
  38. fieldName: 'slug',
  39. inputValue: 'Test Product Name',
  40. },
  41. });
  42. expect(result.slugForEntity).toBe('test-product-name');
  43. });
  44. it('converts uppercase to lowercase', async () => {
  45. const result = await adminClient.query(slugForEntityDocument, {
  46. input: {
  47. entityName: 'Product',
  48. fieldName: 'slug',
  49. inputValue: 'TEST PRODUCT NAME',
  50. },
  51. });
  52. expect(result.slugForEntity).toBe('test-product-name');
  53. });
  54. it('preserves numbers', async () => {
  55. const result = await adminClient.query(slugForEntityDocument, {
  56. input: {
  57. entityName: 'Product',
  58. fieldName: 'slug',
  59. inputValue: 'Product 123 Version 2',
  60. },
  61. });
  62. expect(result.slugForEntity).toBe('product-123-version-2');
  63. });
  64. });
  65. describe('special characters and unicode', () => {
  66. it('removes special characters', async () => {
  67. const result = await adminClient.query(slugForEntityDocument, {
  68. input: {
  69. entityName: 'Product',
  70. fieldName: 'slug',
  71. inputValue: 'Product!@#$%^&*()_+Name',
  72. },
  73. });
  74. expect(result.slugForEntity).toBe('productname');
  75. });
  76. it('handles special characters with spaces', async () => {
  77. const result = await adminClient.query(slugForEntityDocument, {
  78. input: {
  79. entityName: 'Product',
  80. fieldName: 'slug',
  81. inputValue: 'Product !@#$ Name',
  82. },
  83. });
  84. expect(result.slugForEntity).toBe('product-name');
  85. });
  86. it('handles diacritical marks (accents)', async () => {
  87. const result = await adminClient.query(slugForEntityDocument, {
  88. input: {
  89. entityName: 'Product',
  90. fieldName: 'slug',
  91. inputValue: 'Café Français naïve résumé',
  92. },
  93. });
  94. expect(result.slugForEntity).toBe('cafe-francais-naive-resume');
  95. });
  96. it('handles German umlauts', async () => {
  97. const result = await adminClient.query(slugForEntityDocument, {
  98. input: {
  99. entityName: 'Product',
  100. fieldName: 'slug',
  101. inputValue: 'Über größer schön',
  102. },
  103. });
  104. expect(result.slugForEntity).toBe('uber-groer-schon');
  105. });
  106. it('handles Spanish characters', async () => {
  107. const result = await adminClient.query(slugForEntityDocument, {
  108. input: {
  109. entityName: 'Product',
  110. fieldName: 'slug',
  111. inputValue: 'Niño Español Añejo',
  112. },
  113. });
  114. expect(result.slugForEntity).toBe('nino-espanol-anejo');
  115. });
  116. it('handles non-Latin scripts (removes them)', async () => {
  117. const result = await adminClient.query(slugForEntityDocument, {
  118. input: {
  119. entityName: 'Product',
  120. fieldName: 'slug',
  121. inputValue: 'Product 商品 المنتج उत्पाद',
  122. },
  123. });
  124. expect(result.slugForEntity).toBe('product');
  125. });
  126. it('handles emoji (removes them)', async () => {
  127. const result = await adminClient.query(slugForEntityDocument, {
  128. input: {
  129. entityName: 'Product',
  130. fieldName: 'slug',
  131. inputValue: 'Cool Product 😎 🚀 Amazing',
  132. },
  133. });
  134. expect(result.slugForEntity).toBe('cool-product-amazing');
  135. });
  136. it('handles punctuation and symbols', async () => {
  137. const result = await adminClient.query(slugForEntityDocument, {
  138. input: {
  139. entityName: 'Product',
  140. fieldName: 'slug',
  141. inputValue: 'Product: The Best! (Version 2.0) - New & Improved',
  142. },
  143. });
  144. expect(result.slugForEntity).toBe('product-the-best-version-20-new-improved');
  145. });
  146. });
  147. describe('edge cases', () => {
  148. it('handles leading and trailing spaces', async () => {
  149. const result = await adminClient.query(slugForEntityDocument, {
  150. input: {
  151. entityName: 'Product',
  152. fieldName: 'slug',
  153. inputValue: ' Test Product ',
  154. },
  155. });
  156. expect(result.slugForEntity).toBe('test-product');
  157. });
  158. it('handles hyphens correctly', async () => {
  159. const result = await adminClient.query(slugForEntityDocument, {
  160. input: {
  161. entityName: 'Product',
  162. fieldName: 'slug',
  163. inputValue: 'Test--Product---Name',
  164. },
  165. });
  166. expect(result.slugForEntity).toBe('test-product-name');
  167. });
  168. it('handles leading and trailing hyphens', async () => {
  169. const result = await adminClient.query(slugForEntityDocument, {
  170. input: {
  171. entityName: 'Product',
  172. fieldName: 'slug',
  173. inputValue: '-Test Product-',
  174. },
  175. });
  176. expect(result.slugForEntity).toBe('test-product');
  177. });
  178. it('handles empty string', async () => {
  179. const result = await adminClient.query(slugForEntityDocument, {
  180. input: {
  181. entityName: 'Product',
  182. fieldName: 'slug',
  183. inputValue: '',
  184. },
  185. });
  186. expect(result.slugForEntity).toBe('');
  187. });
  188. it('handles only special characters', async () => {
  189. const result = await adminClient.query(slugForEntityDocument, {
  190. input: {
  191. entityName: 'Product',
  192. fieldName: 'slug',
  193. inputValue: '!@#$%^&*()',
  194. },
  195. });
  196. expect(result.slugForEntity).toBe('');
  197. });
  198. it('handles mixed case with numbers and special chars', async () => {
  199. const result = await adminClient.query(slugForEntityDocument, {
  200. input: {
  201. entityName: 'Product',
  202. fieldName: 'slug',
  203. inputValue: '100% Natural & Organic Product #1',
  204. },
  205. });
  206. expect(result.slugForEntity).toBe('100-natural-organic-product-1');
  207. });
  208. });
  209. describe('uniqueness handling', () => {
  210. it('appends number for duplicate slugs', async () => {
  211. // First, create a product with slug 'laptop'
  212. await adminClient.query(createProductDocument, {
  213. input: {
  214. translations: [
  215. {
  216. languageCode: 'en',
  217. name: 'Laptop',
  218. slug: 'laptop',
  219. description: 'A laptop computer',
  220. },
  221. ],
  222. },
  223. });
  224. // Now try to generate slug for another product with the same base slug
  225. const result = await adminClient.query(slugForEntityDocument, {
  226. input: {
  227. entityName: 'Product',
  228. fieldName: 'slug',
  229. inputValue: 'Laptop',
  230. },
  231. });
  232. expect(result.slugForEntity).toBe('laptop-1');
  233. });
  234. it('increments counter for multiple duplicates', async () => {
  235. // Create products with slugs 'phone' and 'phone-1'
  236. await adminClient.query(createProductDocument, {
  237. input: {
  238. translations: [
  239. {
  240. languageCode: 'en',
  241. name: 'Phone',
  242. slug: 'phone',
  243. description: 'A smartphone',
  244. },
  245. ],
  246. },
  247. });
  248. await adminClient.query(createProductDocument, {
  249. input: {
  250. translations: [
  251. {
  252. languageCode: 'en',
  253. name: 'Phone 2',
  254. slug: 'phone-1',
  255. description: 'Another smartphone',
  256. },
  257. ],
  258. },
  259. });
  260. // Now generate slug for another phone
  261. const result = await adminClient.query(slugForEntityDocument, {
  262. input: {
  263. entityName: 'Product',
  264. fieldName: 'slug',
  265. inputValue: 'Phone',
  266. },
  267. });
  268. expect(result.slugForEntity).toBe('phone-2');
  269. });
  270. it('excludes own ID when checking uniqueness', async () => {
  271. // Create a product
  272. const createResult = await adminClient.query(createProductDocument, {
  273. input: {
  274. translations: [
  275. {
  276. languageCode: 'en',
  277. name: 'Tablet',
  278. slug: 'tablet',
  279. description: 'A tablet device',
  280. },
  281. ],
  282. },
  283. });
  284. const productId = createResult.createProduct.id;
  285. // Generate slug for the same product (updating scenario)
  286. const result = await adminClient.query(slugForEntityDocument, {
  287. input: {
  288. entityName: 'Product',
  289. fieldName: 'slug',
  290. inputValue: 'Tablet',
  291. entityId: productId,
  292. },
  293. });
  294. // Should return the same slug without appending number
  295. expect(result.slugForEntity).toBe('tablet');
  296. });
  297. it('works with different entity types', async () => {
  298. // Test with Collection entity (slug field is in CollectionTranslation)
  299. const result = await adminClient.query(slugForEntityDocument, {
  300. input: {
  301. entityName: 'Collection',
  302. fieldName: 'slug',
  303. inputValue: 'Summer Collection 2024',
  304. },
  305. });
  306. expect(result.slugForEntity).toBe('summer-collection-2024');
  307. });
  308. it('handles multi-language slug generation', async () => {
  309. // Create a product with English translation first
  310. const createProduct = await adminClient.query(createProductDocument, {
  311. input: {
  312. translations: [
  313. {
  314. languageCode: LanguageCode.en,
  315. name: 'English Product',
  316. slug: 'english-product',
  317. description: 'Product in English',
  318. },
  319. ],
  320. },
  321. });
  322. const productId = createProduct.createProduct.id;
  323. // Test generating slug for German translation of the same product
  324. const germanResult = await adminClient.query(slugForEntityDocument, {
  325. input: {
  326. entityName: 'Product',
  327. fieldName: 'slug',
  328. inputValue: 'Deutsches Produkt',
  329. entityId: productId,
  330. },
  331. });
  332. expect(germanResult.slugForEntity).toBe('deutsches-produkt');
  333. // Test generating slug for French translation
  334. const frenchResult = await adminClient.query(slugForEntityDocument, {
  335. input: {
  336. entityName: 'Product',
  337. fieldName: 'slug',
  338. inputValue: 'Produit Français',
  339. entityId: productId,
  340. },
  341. });
  342. expect(frenchResult.slugForEntity).toBe('produit-francais');
  343. });
  344. it('handles uniqueness across different language translations', async () => {
  345. // Create first product with multiple language translations
  346. await adminClient.query(createProductDocument, {
  347. input: {
  348. translations: [
  349. {
  350. languageCode: LanguageCode.en,
  351. name: 'Computer',
  352. slug: 'computer',
  353. description: 'A computer',
  354. },
  355. {
  356. languageCode: LanguageCode.de,
  357. name: 'Computer',
  358. slug: 'computer-de',
  359. description: 'Ein Computer',
  360. },
  361. ],
  362. },
  363. });
  364. // Generate slug for a new product with same English name
  365. const englishSlugResult = await adminClient.query(slugForEntityDocument, {
  366. input: {
  367. entityName: 'Product',
  368. fieldName: 'slug',
  369. inputValue: 'Computer',
  370. },
  371. });
  372. expect(englishSlugResult.slugForEntity).toBe('computer-1');
  373. // Generate slug with German input that also conflicts
  374. const germanSlugResult = await adminClient.query(slugForEntityDocument, {
  375. input: {
  376. entityName: 'Product',
  377. fieldName: 'slug',
  378. inputValue: 'Computer DE',
  379. },
  380. });
  381. expect(germanSlugResult.slugForEntity).toBe('computer-de-1');
  382. // Generate slug with French input that doesn't conflict
  383. const frenchSlugResult = await adminClient.query(slugForEntityDocument, {
  384. input: {
  385. entityName: 'Product',
  386. fieldName: 'slug',
  387. inputValue: 'Ordinateur',
  388. },
  389. });
  390. expect(frenchSlugResult.slugForEntity).toBe('ordinateur');
  391. });
  392. it('handles translation entity exclusion correctly with multiple languages', async () => {
  393. // Create a product with multiple language translations
  394. const createProduct = await adminClient.query(createProductDocument, {
  395. input: {
  396. translations: [
  397. {
  398. languageCode: LanguageCode.en,
  399. name: 'Multilingual Product',
  400. slug: 'multilingual-product',
  401. description: 'Product in English',
  402. },
  403. {
  404. languageCode: LanguageCode.de,
  405. name: 'Mehrsprachiges Produkt',
  406. slug: 'mehrsprachiges-produkt',
  407. description: 'Produkt auf Deutsch',
  408. },
  409. ],
  410. },
  411. });
  412. const productId = createProduct.createProduct.id;
  413. // Update English translation - should not conflict with itself
  414. const englishUpdateResult = await adminClient.query(slugForEntityDocument, {
  415. input: {
  416. entityName: 'Product',
  417. fieldName: 'slug',
  418. inputValue: 'Multilingual Product Updated',
  419. entityId: productId,
  420. },
  421. });
  422. expect(englishUpdateResult.slugForEntity).toBe('multilingual-product-updated');
  423. // Update German translation - should not conflict with itself
  424. const germanUpdateResult = await adminClient.query(slugForEntityDocument, {
  425. input: {
  426. entityName: 'Product',
  427. fieldName: 'slug',
  428. inputValue: 'Mehrsprachiges Produkt Aktualisiert',
  429. entityId: productId,
  430. },
  431. });
  432. expect(germanUpdateResult.slugForEntity).toBe('mehrsprachiges-produkt-aktualisiert');
  433. });
  434. });
  435. describe('multi-language collections', () => {
  436. it('generates unique slugs for collection translations', async () => {
  437. // Create a collection with multiple language translations
  438. const createCollection = await adminClient.query(createCollectionDocument, {
  439. input: {
  440. translations: [
  441. {
  442. languageCode: LanguageCode.en,
  443. name: 'Tech Collection',
  444. slug: 'tech-collection',
  445. description: 'Technology products',
  446. },
  447. {
  448. languageCode: LanguageCode.fr,
  449. name: 'Collection Tech',
  450. slug: 'collection-tech',
  451. description: 'Produits technologiques',
  452. },
  453. ],
  454. filters: [],
  455. },
  456. });
  457. const collectionId = createCollection.createCollection.id;
  458. // Test generating new slug for Spanish translation
  459. const spanishResult = await adminClient.query(slugForEntityDocument, {
  460. input: {
  461. entityName: 'Collection',
  462. fieldName: 'slug',
  463. inputValue: 'Colección Tecnológica',
  464. entityId: collectionId,
  465. },
  466. });
  467. expect(spanishResult.slugForEntity).toBe('coleccion-tecnologica');
  468. });
  469. it('handles collection slug conflicts across languages', async () => {
  470. // Create collection with English name
  471. await adminClient.query(createCollectionDocument, {
  472. input: {
  473. translations: [
  474. {
  475. languageCode: LanguageCode.en,
  476. name: 'Fashion Collection',
  477. slug: 'fashion-collection',
  478. description: 'Fashion items',
  479. },
  480. ],
  481. filters: [],
  482. },
  483. });
  484. // Generate slug for another collection with similar name
  485. const result = await adminClient.query(slugForEntityDocument, {
  486. input: {
  487. entityName: 'Collection',
  488. fieldName: 'slug',
  489. inputValue: 'Fashion Collection',
  490. },
  491. });
  492. expect(result.slugForEntity).toBe('fashion-collection-1');
  493. // Test with international name that transliterates to similar slug
  494. const internationalResult = await adminClient.query(slugForEntityDocument, {
  495. input: {
  496. entityName: 'Collection',
  497. fieldName: 'slug',
  498. inputValue: 'Façhion Collêction',
  499. },
  500. });
  501. expect(internationalResult.slugForEntity).toBe('fachion-collection');
  502. });
  503. });
  504. describe('international character handling', () => {
  505. it('handles various language scripts in slug generation', async () => {
  506. // Test different language inputs
  507. const testCases = [
  508. { input: 'Café Français', expected: 'cafe-francais' },
  509. { input: 'Niño Español', expected: 'nino-espanol' },
  510. { input: 'Größer Schön', expected: 'groer-schon' },
  511. { input: 'Naïve Résumé', expected: 'naive-resume' },
  512. { input: 'Crème Brûlée', expected: 'creme-brulee' },
  513. { input: 'Piñata Jalapeño', expected: 'pinata-jalapeno' },
  514. ];
  515. for (const testCase of testCases) {
  516. const result = await adminClient.query(slugForEntityDocument, {
  517. input: {
  518. entityName: 'Product',
  519. fieldName: 'slug',
  520. inputValue: testCase.input,
  521. },
  522. });
  523. expect(result.slugForEntity).toBe(testCase.expected);
  524. }
  525. });
  526. it('handles mixed language input', async () => {
  527. const result = await adminClient.query(slugForEntityDocument, {
  528. input: {
  529. entityName: 'Product',
  530. fieldName: 'slug',
  531. inputValue: 'English Français Español Deutsch Mix',
  532. },
  533. });
  534. expect(result.slugForEntity).toBe('english-francais-espanol-deutsch-mix');
  535. });
  536. });
  537. describe('auto-detection functionality', () => {
  538. it('auto-detects translation entity for slug field', async () => {
  539. // Using base entity name, should automatically detect ProductTranslation
  540. const result = await adminClient.query(slugForEntityDocument, {
  541. input: {
  542. entityName: 'Product',
  543. fieldName: 'slug',
  544. inputValue: 'Auto Detection Test',
  545. },
  546. });
  547. expect(result.slugForEntity).toBe('auto-detection-test');
  548. });
  549. it('works with explicit translation entity names', async () => {
  550. // Still works when explicitly using translation entity name
  551. const result = await adminClient.query(slugForEntityDocument, {
  552. input: {
  553. entityName: 'ProductTranslation',
  554. fieldName: 'slug',
  555. inputValue: 'Explicit Translation Test',
  556. },
  557. });
  558. expect(result.slugForEntity).toBe('explicit-translation-test');
  559. });
  560. it('works with Collection entity auto-detection', async () => {
  561. // Using base entity name, should automatically detect CollectionTranslation
  562. const result = await adminClient.query(slugForEntityDocument, {
  563. input: {
  564. entityName: 'Collection',
  565. fieldName: 'slug',
  566. inputValue: 'Collection Auto Detection',
  567. },
  568. });
  569. expect(result.slugForEntity).toBe('collection-auto-detection');
  570. });
  571. it('auto-detects translation entities for different languages', async () => {
  572. // Test that auto-detection works regardless of the intended language
  573. const testCases = [
  574. { input: 'Auto Detection English', expected: 'auto-detection-english' },
  575. { input: 'Détection Automatique', expected: 'detection-automatique' },
  576. { input: 'Detección Automática', expected: 'deteccion-automatica' },
  577. { input: 'Automatische Erkennung', expected: 'automatische-erkennung' },
  578. ];
  579. for (const testCase of testCases) {
  580. const result = await adminClient.query(slugForEntityDocument, {
  581. input: {
  582. entityName: 'Product',
  583. fieldName: 'slug',
  584. inputValue: testCase.input,
  585. },
  586. });
  587. expect(result.slugForEntity).toBe(testCase.expected);
  588. }
  589. });
  590. });
  591. describe('error handling', () => {
  592. it('throws error for non-existent entity', async () => {
  593. try {
  594. await adminClient.query(slugForEntityDocument, {
  595. input: {
  596. entityName: 'NonExistentEntity',
  597. fieldName: 'slug',
  598. inputValue: 'Test',
  599. },
  600. });
  601. expect.fail('Should have thrown an error');
  602. } catch (error: any) {
  603. expect(error.message).toContain('error.entity-not-found');
  604. }
  605. });
  606. it('throws error for non-existent field', async () => {
  607. try {
  608. await adminClient.query(slugForEntityDocument, {
  609. input: {
  610. entityName: 'Product',
  611. fieldName: 'nonExistentField',
  612. inputValue: 'Test',
  613. },
  614. });
  615. expect.fail('Should have thrown an error');
  616. } catch (error: any) {
  617. expect(error.message).toContain('error.entity-has-no-field');
  618. }
  619. });
  620. });
  621. });
  622. });
  623. const slugForEntityDocument = graphql(`
  624. query SlugForEntity($input: SlugForEntityInput!) {
  625. slugForEntity(input: $input)
  626. }
  627. `);