custom-fields.e2e-spec.ts 40 KB

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