custom-fields.e2e-spec.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219
  1. import { LanguageCode } from '@vendure/common/lib/generated-types';
  2. import { Asset, CustomFields, mergeConfig, TransactionalConnection } from '@vendure/core';
  3. import { createTestEnvironment } from '@vendure/testing';
  4. import { fail } from 'assert';
  5. import gql from 'graphql-tag';
  6. import path from 'path';
  7. import { vi } from 'vitest';
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  9. import { initialData } from '../../../e2e-common/e2e-initial-data';
  10. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  11. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  12. import { fixPostgresTimezone } from './utils/fix-pg-timezone';
  13. fixPostgresTimezone();
  14. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  15. const validateInjectorSpy = vi.fn();
  16. const customConfig = mergeConfig(testConfig(), {
  17. dbConnectionOptions: {
  18. timezone: 'Z',
  19. },
  20. customFields: {
  21. Product: [
  22. { name: 'nullable', type: 'string' },
  23. { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
  24. { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
  25. { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
  26. { name: 'intWithDefault', type: 'int', defaultValue: 5 },
  27. { name: 'floatWithDefault', type: 'float', defaultValue: 5.5678 },
  28. { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
  29. {
  30. name: 'dateTimeWithDefault',
  31. type: 'datetime',
  32. defaultValue: new Date('2019-04-30T12:59:16.4158386Z'),
  33. },
  34. { name: 'validateString', type: 'string', pattern: '^[0-9][a-z]+$' },
  35. { name: 'validateLocaleString', type: 'localeString', pattern: '^[0-9][a-z]+$' },
  36. { name: 'validateInt', type: 'int', min: 0, max: 10 },
  37. { name: 'validateFloat', type: 'float', min: 0.5, max: 10.5 },
  38. {
  39. name: 'validateDateTime',
  40. type: 'datetime',
  41. min: '2019-01-01T08:30',
  42. max: '2019-06-01T08:30',
  43. },
  44. {
  45. name: 'validateFn1',
  46. type: 'string',
  47. validate: value => {
  48. if (value !== 'valid') {
  49. return `The value ['${value as string}'] is not valid`;
  50. }
  51. },
  52. },
  53. {
  54. name: 'validateFn2',
  55. type: 'string',
  56. validate: value => {
  57. if (value !== 'valid') {
  58. return [
  59. {
  60. languageCode: LanguageCode.en,
  61. value: `The value ['${value as string}'] is not valid`,
  62. },
  63. ];
  64. }
  65. },
  66. },
  67. {
  68. name: 'validateFn3',
  69. type: 'string',
  70. validate: (value, injector) => {
  71. const connection = injector.get(TransactionalConnection);
  72. validateInjectorSpy(connection);
  73. },
  74. },
  75. {
  76. name: 'validateFn4',
  77. type: 'string',
  78. validate: async (value, injector) => {
  79. await new Promise(resolve => setTimeout(resolve, 1));
  80. return 'async error';
  81. },
  82. },
  83. {
  84. name: 'validateRelation',
  85. type: 'relation',
  86. entity: Asset,
  87. validate: async value => {
  88. await new Promise(resolve => setTimeout(resolve, 1));
  89. return 'relation error';
  90. },
  91. },
  92. {
  93. name: 'stringWithOptions',
  94. type: 'string',
  95. options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
  96. },
  97. {
  98. name: 'nullableStringWithOptions',
  99. type: 'string',
  100. nullable: true,
  101. options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
  102. },
  103. {
  104. name: 'nonPublic',
  105. type: 'string',
  106. defaultValue: 'hi!',
  107. public: false,
  108. },
  109. {
  110. name: 'public',
  111. type: 'string',
  112. defaultValue: 'ho!',
  113. public: true,
  114. },
  115. {
  116. name: 'longString',
  117. type: 'string',
  118. length: 10000,
  119. },
  120. {
  121. name: 'longLocaleString',
  122. type: 'localeString',
  123. length: 10000,
  124. },
  125. {
  126. name: 'readonlyString',
  127. type: 'string',
  128. readonly: true,
  129. },
  130. {
  131. name: 'internalString',
  132. type: 'string',
  133. internal: true,
  134. },
  135. {
  136. name: 'stringList',
  137. type: 'string',
  138. list: true,
  139. },
  140. {
  141. name: 'localeStringList',
  142. type: 'localeString',
  143. list: true,
  144. },
  145. {
  146. name: 'stringListWithDefault',
  147. type: 'string',
  148. list: true,
  149. defaultValue: ['cat'],
  150. },
  151. {
  152. name: 'intListWithValidation',
  153. type: 'int',
  154. list: true,
  155. validate: value => {
  156. if (!value.includes(42)) {
  157. return 'Must include the number 42!';
  158. }
  159. },
  160. },
  161. {
  162. name: 'uniqueString',
  163. type: 'string',
  164. unique: true,
  165. },
  166. ],
  167. Facet: [
  168. {
  169. name: 'translated',
  170. type: 'localeString',
  171. },
  172. ],
  173. Customer: [
  174. {
  175. name: 'score',
  176. type: 'int',
  177. readonly: true,
  178. },
  179. ],
  180. Collection: [
  181. { name: 'secretKey1', type: 'string', defaultValue: '', public: false, internal: true },
  182. { name: 'secretKey2', type: 'string', defaultValue: '', public: false, internal: false },
  183. ],
  184. OrderLine: [{ name: 'validateInt', type: 'int', min: 0, max: 10 }],
  185. ProductVariantPrice: [
  186. {
  187. name: 'costPrice',
  188. type: 'int',
  189. }
  190. ],
  191. // Single readonly Address custom field to test
  192. // https://github.com/vendure-ecommerce/vendure/issues/3326
  193. Address: [
  194. {
  195. name: 'hereId',
  196. type: 'string',
  197. readonly: true,
  198. nullable: true,
  199. },
  200. ],
  201. } as CustomFields,
  202. });
  203. describe('Custom fields', () => {
  204. const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
  205. beforeAll(async () => {
  206. await server.init({
  207. initialData,
  208. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  209. customerCount: 1,
  210. });
  211. await adminClient.asSuperAdmin();
  212. }, TEST_SETUP_TIMEOUT_MS);
  213. afterAll(async () => {
  214. await server.destroy();
  215. });
  216. it('globalSettings.serverConfig.customFieldConfig', async () => {
  217. const { globalSettings } = await adminClient.query(gql`
  218. query {
  219. globalSettings {
  220. serverConfig {
  221. customFieldConfig {
  222. Product {
  223. ... on CustomField {
  224. name
  225. type
  226. list
  227. }
  228. ... on RelationCustomFieldConfig {
  229. scalarFields
  230. }
  231. }
  232. }
  233. }
  234. }
  235. }
  236. `);
  237. expect(globalSettings.serverConfig.customFieldConfig).toEqual({
  238. Product: [
  239. { name: 'nullable', type: 'string', list: false },
  240. { name: 'notNullable', type: 'string', list: false },
  241. { name: 'stringWithDefault', type: 'string', list: false },
  242. { name: 'localeStringWithDefault', type: 'localeString', list: false },
  243. { name: 'intWithDefault', type: 'int', list: false },
  244. { name: 'floatWithDefault', type: 'float', list: false },
  245. { name: 'booleanWithDefault', type: 'boolean', list: false },
  246. { name: 'dateTimeWithDefault', type: 'datetime', list: false },
  247. { name: 'validateString', type: 'string', list: false },
  248. { name: 'validateLocaleString', type: 'localeString', list: false },
  249. { name: 'validateInt', type: 'int', list: false },
  250. { name: 'validateFloat', type: 'float', list: false },
  251. { name: 'validateDateTime', type: 'datetime', list: false },
  252. { name: 'validateFn1', type: 'string', list: false },
  253. { name: 'validateFn2', type: 'string', list: false },
  254. { name: 'validateFn3', type: 'string', list: false },
  255. { name: 'validateFn4', type: 'string', list: false },
  256. {
  257. name: 'validateRelation',
  258. type: 'relation',
  259. list: false,
  260. scalarFields: [
  261. 'id',
  262. 'createdAt',
  263. 'updatedAt',
  264. 'name',
  265. 'type',
  266. 'fileSize',
  267. 'mimeType',
  268. 'width',
  269. 'height',
  270. 'source',
  271. 'preview',
  272. 'customFields',
  273. ],
  274. },
  275. { name: 'stringWithOptions', type: 'string', list: false },
  276. { name: 'nullableStringWithOptions', type: 'string', list: false },
  277. { name: 'nonPublic', type: 'string', list: false },
  278. { name: 'public', type: 'string', list: false },
  279. { name: 'longString', type: 'string', list: false },
  280. { name: 'longLocaleString', type: 'localeString', list: false },
  281. { name: 'readonlyString', type: 'string', list: false },
  282. { name: 'stringList', type: 'string', list: true },
  283. { name: 'localeStringList', type: 'localeString', list: true },
  284. { name: 'stringListWithDefault', type: 'string', list: true },
  285. { name: 'intListWithValidation', type: 'int', list: true },
  286. { name: 'uniqueString', type: 'string', list: false },
  287. // The internal type should not be exposed at all
  288. // { name: 'internalString', type: 'string' },
  289. ],
  290. });
  291. });
  292. it('globalSettings.serverConfig.entityCustomFields', async () => {
  293. const { globalSettings } = await adminClient.query(gql`
  294. query {
  295. globalSettings {
  296. serverConfig {
  297. entityCustomFields {
  298. entityName
  299. customFields {
  300. ... on CustomField {
  301. name
  302. type
  303. list
  304. }
  305. ... on RelationCustomFieldConfig {
  306. scalarFields
  307. }
  308. }
  309. }
  310. }
  311. }
  312. }
  313. `);
  314. const productCustomFields = globalSettings.serverConfig.entityCustomFields.find(
  315. e => e.entityName === 'Product',
  316. );
  317. expect(productCustomFields).toEqual({
  318. entityName: 'Product',
  319. customFields: [
  320. { name: 'nullable', type: 'string', list: false },
  321. { name: 'notNullable', type: 'string', list: false },
  322. { name: 'stringWithDefault', type: 'string', list: false },
  323. { name: 'localeStringWithDefault', type: 'localeString', list: false },
  324. { name: 'intWithDefault', type: 'int', list: false },
  325. { name: 'floatWithDefault', type: 'float', list: false },
  326. { name: 'booleanWithDefault', type: 'boolean', list: false },
  327. { name: 'dateTimeWithDefault', type: 'datetime', list: false },
  328. { name: 'validateString', type: 'string', list: false },
  329. { name: 'validateLocaleString', type: 'localeString', list: false },
  330. { name: 'validateInt', type: 'int', list: false },
  331. { name: 'validateFloat', type: 'float', list: false },
  332. { name: 'validateDateTime', type: 'datetime', list: false },
  333. { name: 'validateFn1', type: 'string', list: false },
  334. { name: 'validateFn2', type: 'string', list: false },
  335. { name: 'validateFn3', type: 'string', list: false },
  336. { name: 'validateFn4', type: 'string', list: false },
  337. {
  338. name: 'validateRelation',
  339. type: 'relation',
  340. list: false,
  341. scalarFields: [
  342. 'id',
  343. 'createdAt',
  344. 'updatedAt',
  345. 'name',
  346. 'type',
  347. 'fileSize',
  348. 'mimeType',
  349. 'width',
  350. 'height',
  351. 'source',
  352. 'preview',
  353. 'customFields',
  354. ],
  355. },
  356. { name: 'stringWithOptions', type: 'string', list: false },
  357. { name: 'nullableStringWithOptions', type: 'string', list: false },
  358. { name: 'nonPublic', type: 'string', list: false },
  359. { name: 'public', type: 'string', list: false },
  360. { name: 'longString', type: 'string', list: false },
  361. { name: 'longLocaleString', type: 'localeString', list: false },
  362. { name: 'readonlyString', type: 'string', list: false },
  363. { name: 'stringList', type: 'string', list: true },
  364. { name: 'localeStringList', type: 'localeString', list: true },
  365. { name: 'stringListWithDefault', type: 'string', list: true },
  366. { name: 'intListWithValidation', type: 'int', list: true },
  367. { name: 'uniqueString', type: 'string', list: false },
  368. // The internal type should not be exposed at all
  369. // { name: 'internalString', type: 'string' },
  370. ],
  371. });
  372. });
  373. it('get nullable with no default', async () => {
  374. const { product } = await adminClient.query(gql`
  375. query {
  376. product(id: "T_1") {
  377. id
  378. name
  379. customFields {
  380. nullable
  381. }
  382. }
  383. }
  384. `);
  385. expect(product).toEqual({
  386. id: 'T_1',
  387. name: 'Laptop',
  388. customFields: {
  389. nullable: null,
  390. },
  391. });
  392. });
  393. it('get entity with localeString only', async () => {
  394. const { facet } = await adminClient.query(gql`
  395. query {
  396. facet(id: "T_1") {
  397. id
  398. name
  399. customFields {
  400. translated
  401. }
  402. }
  403. }
  404. `);
  405. expect(facet).toEqual({
  406. id: 'T_1',
  407. name: 'category',
  408. customFields: {
  409. translated: null,
  410. },
  411. });
  412. });
  413. it('get fields with default values', async () => {
  414. const { product } = await adminClient.query(gql`
  415. query {
  416. product(id: "T_1") {
  417. id
  418. name
  419. customFields {
  420. stringWithDefault
  421. localeStringWithDefault
  422. intWithDefault
  423. floatWithDefault
  424. booleanWithDefault
  425. dateTimeWithDefault
  426. stringListWithDefault
  427. }
  428. }
  429. }
  430. `);
  431. const customFields = {
  432. stringWithDefault: 'hello',
  433. localeStringWithDefault: 'hola',
  434. intWithDefault: 5,
  435. floatWithDefault: 5.5678,
  436. booleanWithDefault: true,
  437. dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
  438. // MySQL does not support defaults on TEXT fields, which is what "simple-json" uses
  439. // internally. See https://stackoverflow.com/q/3466872/772859
  440. stringListWithDefault: customConfig.dbConnectionOptions.type === 'mysql' ? null : ['cat'],
  441. };
  442. expect(product).toEqual({
  443. id: 'T_1',
  444. name: 'Laptop',
  445. customFields,
  446. });
  447. });
  448. it(
  449. 'update non-nullable field',
  450. assertThrowsWithMessage(async () => {
  451. await adminClient.query(gql`
  452. mutation {
  453. updateProduct(input: { id: "T_1", customFields: { notNullable: null } }) {
  454. id
  455. }
  456. }
  457. `);
  458. }, 'The custom field "notNullable" value cannot be set to null'),
  459. );
  460. it(
  461. 'throws on attempt to update readonly field',
  462. assertThrowsWithMessage(async () => {
  463. await adminClient.query(gql`
  464. mutation {
  465. updateProduct(input: { id: "T_1", customFields: { readonlyString: "hello" } }) {
  466. id
  467. }
  468. }
  469. `);
  470. }, 'Field "readonlyString" is not defined by type "UpdateProductCustomFieldsInput"'),
  471. );
  472. it(
  473. 'throws on attempt to update readonly field when no other custom fields defined',
  474. assertThrowsWithMessage(async () => {
  475. await adminClient.query(gql`
  476. mutation {
  477. updateCustomer(input: { id: "T_1", customFields: { score: 5 } }) {
  478. ... on Customer {
  479. id
  480. }
  481. }
  482. }
  483. `);
  484. }, 'The custom field "score" is readonly'),
  485. );
  486. it(
  487. 'throws on attempt to create readonly field',
  488. assertThrowsWithMessage(async () => {
  489. await adminClient.query(gql`
  490. mutation {
  491. createProduct(
  492. input: {
  493. translations: [{ languageCode: en, name: "test" }]
  494. customFields: { readonlyString: "hello" }
  495. }
  496. ) {
  497. id
  498. }
  499. }
  500. `);
  501. }, 'Field "readonlyString" is not defined by type "CreateProductCustomFieldsInput"'),
  502. );
  503. it('string length allows long strings', async () => {
  504. const longString = Array.from({ length: 500 }, v => 'hello there!').join(' ');
  505. const result = await adminClient.query(
  506. gql`
  507. mutation ($stringValue: String!) {
  508. updateProduct(input: { id: "T_1", customFields: { longString: $stringValue } }) {
  509. id
  510. customFields {
  511. longString
  512. }
  513. }
  514. }
  515. `,
  516. { stringValue: longString },
  517. );
  518. expect(result.updateProduct.customFields.longString).toBe(longString);
  519. });
  520. it('string length allows long localeStrings', async () => {
  521. const longString = Array.from({ length: 500 }, v => 'hello there!').join(' ');
  522. const result = await adminClient.query(
  523. gql`
  524. mutation ($stringValue: String!) {
  525. updateProduct(
  526. input: {
  527. id: "T_1"
  528. translations: [
  529. { languageCode: en, customFields: { longLocaleString: $stringValue } }
  530. ]
  531. }
  532. ) {
  533. id
  534. customFields {
  535. longLocaleString
  536. }
  537. }
  538. }
  539. `,
  540. { stringValue: longString },
  541. );
  542. expect(result.updateProduct.customFields.longLocaleString).toBe(longString);
  543. });
  544. describe('validation', () => {
  545. it(
  546. 'invalid string',
  547. assertThrowsWithMessage(async () => {
  548. await adminClient.query(gql`
  549. mutation {
  550. updateProduct(input: { id: "T_1", customFields: { validateString: "hello" } }) {
  551. id
  552. }
  553. }
  554. `);
  555. }, 'The custom field "validateString" value ["hello"] does not match the pattern [^[0-9][a-z]+$]'),
  556. );
  557. it(
  558. 'invalid string option',
  559. assertThrowsWithMessage(async () => {
  560. await adminClient.query(gql`
  561. mutation {
  562. updateProduct(input: { id: "T_1", customFields: { stringWithOptions: "tiny" } }) {
  563. id
  564. }
  565. }
  566. `);
  567. }, "The custom field \"stringWithOptions\" value [\"tiny\"] is invalid. Valid options are ['small', 'medium', 'large']"),
  568. );
  569. it('valid string option', async () => {
  570. const { updateProduct } = await adminClient.query(gql`
  571. mutation {
  572. updateProduct(input: { id: "T_1", customFields: { stringWithOptions: "medium" } }) {
  573. id
  574. customFields {
  575. stringWithOptions
  576. }
  577. }
  578. }
  579. `);
  580. expect(updateProduct.customFields.stringWithOptions).toBe('medium');
  581. });
  582. it('nullable string option with null', async () => {
  583. const { updateProduct } = await adminClient.query(gql`
  584. mutation {
  585. updateProduct(input: { id: "T_1", customFields: { nullableStringWithOptions: null } }) {
  586. id
  587. customFields {
  588. nullableStringWithOptions
  589. }
  590. }
  591. }
  592. `);
  593. expect(updateProduct.customFields.nullableStringWithOptions).toBeNull();
  594. });
  595. it(
  596. 'invalid localeString',
  597. assertThrowsWithMessage(async () => {
  598. await adminClient.query(gql`
  599. mutation {
  600. updateProduct(
  601. input: {
  602. id: "T_1"
  603. translations: [
  604. {
  605. id: "T_1"
  606. languageCode: en
  607. customFields: { validateLocaleString: "servus" }
  608. }
  609. ]
  610. }
  611. ) {
  612. id
  613. }
  614. }
  615. `);
  616. }, 'The custom field "validateLocaleString" value ["servus"] does not match the pattern [^[0-9][a-z]+$]'),
  617. );
  618. it(
  619. 'invalid int',
  620. assertThrowsWithMessage(async () => {
  621. await adminClient.query(gql`
  622. mutation {
  623. updateProduct(input: { id: "T_1", customFields: { validateInt: 12 } }) {
  624. id
  625. }
  626. }
  627. `);
  628. }, 'The custom field "validateInt" value [12] is greater than the maximum [10]'),
  629. );
  630. it(
  631. 'invalid float',
  632. assertThrowsWithMessage(async () => {
  633. await adminClient.query(gql`
  634. mutation {
  635. updateProduct(input: { id: "T_1", customFields: { validateFloat: 10.6 } }) {
  636. id
  637. }
  638. }
  639. `);
  640. }, 'The custom field "validateFloat" value [10.6] is greater than the maximum [10.5]'),
  641. );
  642. it(
  643. 'invalid datetime',
  644. assertThrowsWithMessage(async () => {
  645. await adminClient.query(gql`
  646. mutation {
  647. updateProduct(
  648. input: {
  649. id: "T_1"
  650. customFields: { validateDateTime: "2019-01-01T05:25:00.000Z" }
  651. }
  652. ) {
  653. id
  654. }
  655. }
  656. `);
  657. }, 'The custom field "validateDateTime" value [2019-01-01T05:25:00.000Z] is less than the minimum [2019-01-01T08:30]'),
  658. );
  659. it(
  660. 'invalid validate function with string',
  661. assertThrowsWithMessage(async () => {
  662. await adminClient.query(gql`
  663. mutation {
  664. updateProduct(input: { id: "T_1", customFields: { validateFn1: "invalid" } }) {
  665. id
  666. }
  667. }
  668. `);
  669. }, "The value ['invalid'] is not valid"),
  670. );
  671. it(
  672. 'invalid validate function with localized string',
  673. assertThrowsWithMessage(async () => {
  674. await adminClient.query(gql`
  675. mutation {
  676. updateProduct(input: { id: "T_1", customFields: { validateFn2: "invalid" } }) {
  677. id
  678. }
  679. }
  680. `);
  681. }, "The value ['invalid'] is not valid"),
  682. );
  683. it(
  684. 'invalid list field',
  685. assertThrowsWithMessage(async () => {
  686. await adminClient.query(gql`
  687. mutation {
  688. updateProduct(
  689. input: { id: "T_1", customFields: { intListWithValidation: [1, 2, 3] } }
  690. ) {
  691. id
  692. }
  693. }
  694. `);
  695. }, 'Must include the number 42!'),
  696. );
  697. it('valid list field', async () => {
  698. const { updateProduct } = await adminClient.query(gql`
  699. mutation {
  700. updateProduct(input: { id: "T_1", customFields: { intListWithValidation: [1, 42, 3] } }) {
  701. id
  702. customFields {
  703. intListWithValidation
  704. }
  705. }
  706. }
  707. `);
  708. expect(updateProduct.customFields.intListWithValidation).toEqual([1, 42, 3]);
  709. });
  710. it('can inject providers into validation fn', async () => {
  711. const { updateProduct } = await adminClient.query(gql`
  712. mutation {
  713. updateProduct(input: { id: "T_1", customFields: { validateFn3: "some value" } }) {
  714. id
  715. customFields {
  716. validateFn3
  717. }
  718. }
  719. }
  720. `);
  721. expect(updateProduct.customFields.validateFn3).toBe('some value');
  722. expect(validateInjectorSpy).toHaveBeenCalledTimes(1);
  723. expect(validateInjectorSpy.mock.calls[0][0] instanceof TransactionalConnection).toBe(true);
  724. });
  725. it(
  726. 'supports async validation fn',
  727. assertThrowsWithMessage(async () => {
  728. await adminClient.query(gql`
  729. mutation {
  730. updateProduct(input: { id: "T_1", customFields: { validateFn4: "some value" } }) {
  731. id
  732. customFields {
  733. validateFn4
  734. }
  735. }
  736. }
  737. `);
  738. }, 'async error'),
  739. );
  740. // https://github.com/vendure-ecommerce/vendure/issues/1000
  741. it(
  742. 'supports validation of relation types',
  743. assertThrowsWithMessage(async () => {
  744. await adminClient.query(gql`
  745. mutation {
  746. updateProduct(input: { id: "T_1", customFields: { validateRelationId: "T_1" } }) {
  747. id
  748. customFields {
  749. validateFn4
  750. }
  751. }
  752. }
  753. `);
  754. }, 'relation error'),
  755. );
  756. // https://github.com/vendure-ecommerce/vendure/issues/1091
  757. it('handles well graphql internal fields', async () => {
  758. // throws "Cannot read property 'args' of undefined" if broken
  759. await adminClient.query(gql`
  760. mutation {
  761. __typename
  762. updateProduct(input: { id: "T_1", customFields: { nullable: "some value" } }) {
  763. __typename
  764. id
  765. customFields {
  766. __typename
  767. nullable
  768. }
  769. }
  770. }
  771. `);
  772. });
  773. // https://github.com/vendure-ecommerce/vendure/issues/1953
  774. describe('validation of OrderLine custom fields', () => {
  775. it('addItemToOrder', async () => {
  776. try {
  777. const { addItemToOrder } = await shopClient.query(gql`
  778. mutation {
  779. addItemToOrder(
  780. productVariantId: 1
  781. quantity: 1
  782. customFields: { validateInt: 11 }
  783. ) {
  784. ... on Order {
  785. id
  786. }
  787. }
  788. }
  789. `);
  790. fail('Should have thrown');
  791. } catch (e) {
  792. expect(e.message).toContain(
  793. 'The custom field "validateInt" value [11] is greater than the maximum [10]',
  794. );
  795. }
  796. const { addItemToOrder: result } = await shopClient.query(gql`
  797. mutation {
  798. addItemToOrder(productVariantId: 1, quantity: 1, customFields: { validateInt: 9 }) {
  799. ... on Order {
  800. id
  801. lines {
  802. customFields {
  803. validateInt
  804. }
  805. }
  806. }
  807. }
  808. }
  809. `);
  810. expect(result.lines[0].customFields).toEqual({ validateInt: 9 });
  811. });
  812. it('adjustOrderLine', async () => {
  813. try {
  814. const { adjustOrderLine } = await shopClient.query(gql`
  815. mutation {
  816. adjustOrderLine(
  817. orderLineId: "T_1"
  818. quantity: 1
  819. customFields: { validateInt: 11 }
  820. ) {
  821. ... on Order {
  822. id
  823. }
  824. }
  825. }
  826. `);
  827. fail('Should have thrown');
  828. } catch (e) {
  829. expect(e.message).toContain(
  830. 'The custom field "validateInt" value [11] is greater than the maximum [10]',
  831. );
  832. }
  833. const { adjustOrderLine: result } = await shopClient.query(gql`
  834. mutation {
  835. adjustOrderLine(orderLineId: "T_1", quantity: 1, customFields: { validateInt: 2 }) {
  836. ... on Order {
  837. id
  838. lines {
  839. customFields {
  840. validateInt
  841. }
  842. }
  843. }
  844. }
  845. }
  846. `);
  847. expect(result.lines[0].customFields).toEqual({ validateInt: 2 });
  848. });
  849. });
  850. });
  851. describe('public access', () => {
  852. it(
  853. 'non-public throws for Shop API',
  854. assertThrowsWithMessage(async () => {
  855. await shopClient.query(gql`
  856. query {
  857. product(id: "T_1") {
  858. id
  859. customFields {
  860. nonPublic
  861. }
  862. }
  863. }
  864. `);
  865. }, 'Cannot query field "nonPublic" on type "ProductCustomFields"'),
  866. );
  867. it('publicly accessible via Shop API', async () => {
  868. const { product } = await shopClient.query(gql`
  869. query {
  870. product(id: "T_1") {
  871. id
  872. customFields {
  873. public
  874. }
  875. }
  876. }
  877. `);
  878. expect(product.customFields.public).toBe('ho!');
  879. });
  880. it(
  881. 'internal throws for Shop API',
  882. assertThrowsWithMessage(async () => {
  883. await shopClient.query(gql`
  884. query {
  885. product(id: "T_1") {
  886. id
  887. customFields {
  888. internalString
  889. }
  890. }
  891. }
  892. `);
  893. }, 'Cannot query field "internalString" on type "ProductCustomFields"'),
  894. );
  895. it(
  896. 'internal throws for Admin API',
  897. assertThrowsWithMessage(async () => {
  898. await adminClient.query(gql`
  899. query {
  900. product(id: "T_1") {
  901. id
  902. customFields {
  903. internalString
  904. }
  905. }
  906. }
  907. `);
  908. }, 'Cannot query field "internalString" on type "ProductCustomFields"'),
  909. );
  910. // https://github.com/vendure-ecommerce/vendure/issues/3049
  911. it('does not leak private fields via JSON type', async () => {
  912. const { collection } = await shopClient.query(gql`
  913. query {
  914. collection(id: "T_1") {
  915. id
  916. customFields
  917. }
  918. }
  919. `);
  920. expect(collection.customFields).toBe(null);
  921. });
  922. });
  923. describe('sort & filter', () => {
  924. it('can sort by custom fields', async () => {
  925. const { products } = await adminClient.query(gql`
  926. query {
  927. products(options: { sort: { nullable: ASC } }) {
  928. totalItems
  929. }
  930. }
  931. `);
  932. expect(products.totalItems).toBe(1);
  933. });
  934. // https://github.com/vendure-ecommerce/vendure/issues/1581
  935. it('can sort by localeString custom fields', async () => {
  936. const { products } = await adminClient.query(gql`
  937. query {
  938. products(options: { sort: { localeStringWithDefault: ASC } }) {
  939. totalItems
  940. }
  941. }
  942. `);
  943. expect(products.totalItems).toBe(1);
  944. });
  945. it('can filter by custom fields', async () => {
  946. const { products } = await adminClient.query(gql`
  947. query {
  948. products(options: { filter: { stringWithDefault: { contains: "hello" } } }) {
  949. totalItems
  950. }
  951. }
  952. `);
  953. expect(products.totalItems).toBe(1);
  954. });
  955. it('can filter by localeString custom fields', async () => {
  956. const { products } = await adminClient.query(gql`
  957. query {
  958. products(options: { filter: { localeStringWithDefault: { contains: "hola" } } }) {
  959. totalItems
  960. }
  961. }
  962. `);
  963. expect(products.totalItems).toBe(1);
  964. });
  965. it('can filter by custom list fields', async () => {
  966. const { products: result1 } = await adminClient.query(gql`
  967. query {
  968. products(options: { filter: { intListWithValidation: { inList: 42 } } }) {
  969. totalItems
  970. }
  971. }
  972. `);
  973. expect(result1.totalItems).toBe(1);
  974. const { products: result2 } = await adminClient.query(gql`
  975. query {
  976. products(options: { filter: { intListWithValidation: { inList: 43 } } }) {
  977. totalItems
  978. }
  979. }
  980. `);
  981. expect(result2.totalItems).toBe(0);
  982. });
  983. it(
  984. 'cannot sort by custom list fields',
  985. assertThrowsWithMessage(async () => {
  986. await adminClient.query(gql`
  987. query {
  988. products(options: { sort: { intListWithValidation: ASC } }) {
  989. totalItems
  990. }
  991. }
  992. `);
  993. }, 'Field "intListWithValidation" is not defined by type "ProductSortParameter".'),
  994. );
  995. it(
  996. 'cannot filter by internal field in Admin API',
  997. assertThrowsWithMessage(async () => {
  998. await adminClient.query(gql`
  999. query {
  1000. products(options: { filter: { internalString: { contains: "hello" } } }) {
  1001. totalItems
  1002. }
  1003. }
  1004. `);
  1005. }, 'Field "internalString" is not defined by type "ProductFilterParameter"'),
  1006. );
  1007. it(
  1008. 'cannot filter by internal field in Shop API',
  1009. assertThrowsWithMessage(async () => {
  1010. await shopClient.query(gql`
  1011. query {
  1012. products(options: { filter: { internalString: { contains: "hello" } } }) {
  1013. totalItems
  1014. }
  1015. }
  1016. `);
  1017. }, 'Field "internalString" is not defined by type "ProductFilterParameter"'),
  1018. );
  1019. });
  1020. describe('product on productVariant entity', () => {
  1021. it('is translated', async () => {
  1022. const { productVariants } = await adminClient.query(gql`
  1023. query {
  1024. productVariants(productId: "T_1") {
  1025. items {
  1026. product {
  1027. name
  1028. id
  1029. customFields {
  1030. localeStringWithDefault
  1031. stringWithDefault
  1032. }
  1033. }
  1034. }
  1035. }
  1036. }
  1037. `);
  1038. expect(productVariants.items[0].product).toEqual({
  1039. id: 'T_1',
  1040. name: 'Laptop',
  1041. customFields: {
  1042. localeStringWithDefault: 'hola',
  1043. stringWithDefault: 'hello',
  1044. },
  1045. });
  1046. });
  1047. });
  1048. describe('unique constraint', () => {
  1049. it('setting unique value works', async () => {
  1050. const result = await adminClient.query(gql`
  1051. mutation {
  1052. updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) {
  1053. id
  1054. customFields {
  1055. uniqueString
  1056. }
  1057. }
  1058. }
  1059. `);
  1060. expect(result.updateProduct.customFields.uniqueString).toBe('foo');
  1061. });
  1062. it('setting conflicting value fails', async () => {
  1063. try {
  1064. await adminClient.query(gql`
  1065. mutation {
  1066. createProduct(
  1067. input: {
  1068. translations: [
  1069. { languageCode: en, name: "test 2", slug: "test-2", description: "" }
  1070. ]
  1071. customFields: { uniqueString: "foo" }
  1072. }
  1073. ) {
  1074. id
  1075. }
  1076. }
  1077. `);
  1078. fail('Should have thrown');
  1079. } catch (e: any) {
  1080. let duplicateKeyErrMessage = 'unassigned';
  1081. switch (customConfig.dbConnectionOptions.type) {
  1082. case 'mariadb':
  1083. case 'mysql':
  1084. duplicateKeyErrMessage = "ER_DUP_ENTRY: Duplicate entry 'foo' for key";
  1085. break;
  1086. case 'postgres':
  1087. duplicateKeyErrMessage = 'duplicate key value violates unique constraint';
  1088. break;
  1089. case 'sqlite':
  1090. case 'sqljs':
  1091. duplicateKeyErrMessage = 'UNIQUE constraint failed: product.customFieldsUniquestring';
  1092. break;
  1093. }
  1094. expect(e.message).toContain(duplicateKeyErrMessage);
  1095. }
  1096. });
  1097. });
  1098. it('on ProductVariantPrice', async () => {
  1099. const { updateProductVariants } = await adminClient.query(
  1100. gql`
  1101. mutation UpdateProductVariants($input: [UpdateProductVariantInput!]!) {
  1102. updateProductVariants(input: $input) {
  1103. id
  1104. prices {
  1105. currencyCode
  1106. price
  1107. customFields {
  1108. costPrice
  1109. }
  1110. }
  1111. }
  1112. }
  1113. `,
  1114. {
  1115. input: [
  1116. {
  1117. id: 'T_1',
  1118. prices: [
  1119. {
  1120. price: 129900,
  1121. currencyCode: 'USD',
  1122. customFields: {
  1123. costPrice: 100,
  1124. },
  1125. },
  1126. ],
  1127. },
  1128. ],
  1129. },
  1130. );
  1131. expect(updateProductVariants[0].prices).toEqual([
  1132. {
  1133. currencyCode: 'USD',
  1134. price: 129900,
  1135. customFields: {
  1136. costPrice: 100,
  1137. },
  1138. },
  1139. ]);
  1140. });
  1141. });