custom-fields.e2e-spec.ts 44 KB

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