custom-fields.e2e-spec.ts 42 KB

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