custom-fields.e2e-spec.ts 36 KB

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