custom-fields.e2e-spec.ts 42 KB

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