custom-fields.e2e-spec.ts 36 KB

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