custom-fields.e2e-spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  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 gql from 'graphql-tag';
  5. import path from 'path';
  6. import { initialData } from '../../../e2e-common/e2e-initial-data';
  7. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  8. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  9. import { fixPostgresTimezone } from './utils/fix-pg-timezone';
  10. fixPostgresTimezone();
  11. // tslint:disable:no-non-null-assertion
  12. const validateInjectorSpy = jest.fn();
  13. const customConfig = mergeConfig(testConfig, {
  14. dbConnectionOptions: {
  15. timezone: 'Z',
  16. },
  17. customFields: {
  18. Product: [
  19. { name: 'nullable', type: 'string' },
  20. { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
  21. { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
  22. { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
  23. { name: 'intWithDefault', type: 'int', defaultValue: 5 },
  24. { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
  25. { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
  26. {
  27. name: 'dateTimeWithDefault',
  28. type: 'datetime',
  29. defaultValue: new Date('2019-04-30T12:59:16.4158386Z'),
  30. },
  31. { name: 'validateString', type: 'string', pattern: '^[0-9][a-z]+$' },
  32. { name: 'validateLocaleString', type: 'localeString', pattern: '^[0-9][a-z]+$' },
  33. { name: 'validateInt', type: 'int', min: 0, max: 10 },
  34. { name: 'validateFloat', type: 'float', min: 0.5, max: 10.5 },
  35. {
  36. name: 'validateDateTime',
  37. type: 'datetime',
  38. min: '2019-01-01T08:30',
  39. max: '2019-06-01T08:30',
  40. },
  41. {
  42. name: 'validateFn1',
  43. type: 'string',
  44. validate: value => {
  45. if (value !== 'valid') {
  46. return `The value ['${value}'] is not valid`;
  47. }
  48. },
  49. },
  50. {
  51. name: 'validateFn2',
  52. type: 'string',
  53. validate: value => {
  54. if (value !== 'valid') {
  55. return [
  56. {
  57. languageCode: LanguageCode.en,
  58. value: `The value ['${value}'] is not valid`,
  59. },
  60. ];
  61. }
  62. },
  63. },
  64. {
  65. name: 'validateFn3',
  66. type: 'string',
  67. validate: (value, injector) => {
  68. const connection = injector.get(TransactionalConnection);
  69. validateInjectorSpy(connection);
  70. },
  71. },
  72. {
  73. name: 'validateFn4',
  74. type: 'string',
  75. validate: async (value, injector) => {
  76. await new Promise(resolve => setTimeout(resolve, 1));
  77. return `async error`;
  78. },
  79. },
  80. {
  81. name: 'validateRelation',
  82. type: 'relation',
  83. entity: Asset,
  84. validate: async value => {
  85. await new Promise(resolve => setTimeout(resolve, 1));
  86. return `relation error`;
  87. },
  88. },
  89. {
  90. name: 'stringWithOptions',
  91. type: 'string',
  92. options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
  93. },
  94. {
  95. name: 'nonPublic',
  96. type: 'string',
  97. defaultValue: 'hi!',
  98. public: false,
  99. },
  100. {
  101. name: 'public',
  102. type: 'string',
  103. defaultValue: 'ho!',
  104. public: true,
  105. },
  106. {
  107. name: 'longString',
  108. type: 'string',
  109. length: 10000,
  110. },
  111. {
  112. name: 'longLocaleString',
  113. type: 'localeString',
  114. length: 10000,
  115. },
  116. {
  117. name: 'readonlyString',
  118. type: 'string',
  119. readonly: true,
  120. },
  121. {
  122. name: 'internalString',
  123. type: 'string',
  124. internal: true,
  125. },
  126. {
  127. name: 'stringList',
  128. type: 'string',
  129. list: true,
  130. },
  131. {
  132. name: 'localeStringList',
  133. type: 'localeString',
  134. list: true,
  135. },
  136. {
  137. name: 'stringListWithDefault',
  138. type: 'string',
  139. list: true,
  140. defaultValue: ['cat'],
  141. },
  142. {
  143. name: 'intListWithValidation',
  144. type: 'int',
  145. list: true,
  146. validate: value => {
  147. if (!value.includes(42)) {
  148. return `Must include the number 42!`;
  149. }
  150. },
  151. },
  152. ],
  153. Facet: [
  154. {
  155. name: 'translated',
  156. type: 'localeString',
  157. },
  158. ],
  159. Customer: [
  160. {
  161. name: 'score',
  162. type: 'int',
  163. readonly: true,
  164. },
  165. ],
  166. } as CustomFields,
  167. });
  168. describe('Custom fields', () => {
  169. const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
  170. beforeAll(async () => {
  171. await server.init({
  172. initialData,
  173. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  174. customerCount: 1,
  175. });
  176. await adminClient.asSuperAdmin();
  177. }, TEST_SETUP_TIMEOUT_MS);
  178. afterAll(async () => {
  179. await server.destroy();
  180. });
  181. it('globalSettings.serverConfig.customFieldConfig', async () => {
  182. const { globalSettings } = await adminClient.query(gql`
  183. query {
  184. globalSettings {
  185. serverConfig {
  186. customFieldConfig {
  187. Product {
  188. ... on CustomField {
  189. name
  190. type
  191. list
  192. }
  193. }
  194. }
  195. }
  196. }
  197. }
  198. `);
  199. expect(globalSettings.serverConfig.customFieldConfig).toEqual({
  200. Product: [
  201. { name: 'nullable', type: 'string', list: false },
  202. { name: 'notNullable', type: 'string', list: false },
  203. { name: 'stringWithDefault', type: 'string', list: false },
  204. { name: 'localeStringWithDefault', type: 'localeString', list: false },
  205. { name: 'intWithDefault', type: 'int', list: false },
  206. { name: 'floatWithDefault', type: 'float', list: false },
  207. { name: 'booleanWithDefault', type: 'boolean', list: false },
  208. { name: 'dateTimeWithDefault', type: 'datetime', list: false },
  209. { name: 'validateString', type: 'string', list: false },
  210. { name: 'validateLocaleString', type: 'localeString', list: false },
  211. { name: 'validateInt', type: 'int', list: false },
  212. { name: 'validateFloat', type: 'float', list: false },
  213. { name: 'validateDateTime', type: 'datetime', list: false },
  214. { name: 'validateFn1', type: 'string', list: false },
  215. { name: 'validateFn2', type: 'string', list: false },
  216. { name: 'validateFn3', type: 'string', list: false },
  217. { name: 'validateFn4', type: 'string', list: false },
  218. { name: 'validateRelation', type: 'relation', list: false },
  219. { name: 'stringWithOptions', type: 'string', list: false },
  220. { name: 'nonPublic', type: 'string', list: false },
  221. { name: 'public', type: 'string', list: false },
  222. { name: 'longString', type: 'string', list: false },
  223. { name: 'longLocaleString', type: 'localeString', list: false },
  224. { name: 'readonlyString', type: 'string', list: false },
  225. { name: 'stringList', type: 'string', list: true },
  226. { name: 'localeStringList', type: 'localeString', list: true },
  227. { name: 'stringListWithDefault', type: 'string', list: true },
  228. { name: 'intListWithValidation', type: 'int', list: true },
  229. // The internal type should not be exposed at all
  230. // { name: 'internalString', type: 'string' },
  231. ],
  232. });
  233. });
  234. it('get nullable with no default', async () => {
  235. const { product } = await adminClient.query(gql`
  236. query {
  237. product(id: "T_1") {
  238. id
  239. name
  240. customFields {
  241. nullable
  242. }
  243. }
  244. }
  245. `);
  246. expect(product).toEqual({
  247. id: 'T_1',
  248. name: 'Laptop',
  249. customFields: {
  250. nullable: null,
  251. },
  252. });
  253. });
  254. it('get entity with localeString only', async () => {
  255. const { facet } = await adminClient.query(gql`
  256. query {
  257. facet(id: "T_1") {
  258. id
  259. name
  260. customFields {
  261. translated
  262. }
  263. }
  264. }
  265. `);
  266. expect(facet).toEqual({
  267. id: 'T_1',
  268. name: 'category',
  269. customFields: {
  270. translated: null,
  271. },
  272. });
  273. });
  274. it('get fields with default values', async () => {
  275. const { product } = await adminClient.query(gql`
  276. query {
  277. product(id: "T_1") {
  278. id
  279. name
  280. customFields {
  281. stringWithDefault
  282. localeStringWithDefault
  283. intWithDefault
  284. floatWithDefault
  285. booleanWithDefault
  286. dateTimeWithDefault
  287. stringListWithDefault
  288. }
  289. }
  290. }
  291. `);
  292. const customFields = {
  293. stringWithDefault: 'hello',
  294. localeStringWithDefault: 'hola',
  295. intWithDefault: 5,
  296. floatWithDefault: 5.5,
  297. booleanWithDefault: true,
  298. dateTimeWithDefault: '2019-04-30T12:59:16.415Z',
  299. // MySQL does not support defaults on TEXT fields, which is what "simple-json" uses
  300. // internally. See https://stackoverflow.com/q/3466872/772859
  301. stringListWithDefault: testConfig.dbConnectionOptions.type === 'mysql' ? null : ['cat'],
  302. };
  303. expect(product).toEqual({
  304. id: 'T_1',
  305. name: 'Laptop',
  306. customFields,
  307. });
  308. });
  309. it(
  310. 'update non-nullable field',
  311. assertThrowsWithMessage(async () => {
  312. await adminClient.query(gql`
  313. mutation {
  314. updateProduct(input: { id: "T_1", customFields: { notNullable: null } }) {
  315. id
  316. }
  317. }
  318. `);
  319. }, "The custom field 'notNullable' value cannot be set to null"),
  320. );
  321. it(
  322. 'throws on attempt to update readonly field',
  323. assertThrowsWithMessage(async () => {
  324. await adminClient.query(gql`
  325. mutation {
  326. updateProduct(input: { id: "T_1", customFields: { readonlyString: "hello" } }) {
  327. id
  328. }
  329. }
  330. `);
  331. }, `Field "readonlyString" is not defined by type "UpdateProductCustomFieldsInput"`),
  332. );
  333. it(
  334. 'throws on attempt to update readonly field when no other custom fields defined',
  335. assertThrowsWithMessage(async () => {
  336. await adminClient.query(gql`
  337. mutation {
  338. updateCustomer(input: { id: "T_1", customFields: { score: 5 } }) {
  339. ... on Customer {
  340. id
  341. }
  342. }
  343. }
  344. `);
  345. }, `The custom field 'score' is readonly`),
  346. );
  347. it(
  348. 'throws on attempt to create readonly field',
  349. assertThrowsWithMessage(async () => {
  350. await adminClient.query(gql`
  351. mutation {
  352. createProduct(
  353. input: {
  354. translations: [{ languageCode: en, name: "test" }]
  355. customFields: { readonlyString: "hello" }
  356. }
  357. ) {
  358. id
  359. }
  360. }
  361. `);
  362. }, `Field "readonlyString" is not defined by type "CreateProductCustomFieldsInput"`),
  363. );
  364. it('string length allows long strings', async () => {
  365. const longString = Array.from({ length: 500 }, v => 'hello there!').join(' ');
  366. const result = await adminClient.query(
  367. gql`
  368. mutation($stringValue: String!) {
  369. updateProduct(input: { id: "T_1", customFields: { longString: $stringValue } }) {
  370. id
  371. customFields {
  372. longString
  373. }
  374. }
  375. }
  376. `,
  377. { stringValue: longString },
  378. );
  379. expect(result.updateProduct.customFields.longString).toBe(longString);
  380. });
  381. it('string length allows long localeStrings', 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(
  387. input: {
  388. id: "T_1"
  389. translations: [
  390. { languageCode: en, customFields: { longLocaleString: $stringValue } }
  391. ]
  392. }
  393. ) {
  394. id
  395. customFields {
  396. longLocaleString
  397. }
  398. }
  399. }
  400. `,
  401. { stringValue: longString },
  402. );
  403. expect(result.updateProduct.customFields.longLocaleString).toBe(longString);
  404. });
  405. describe('validation', () => {
  406. it(
  407. 'invalid string',
  408. assertThrowsWithMessage(async () => {
  409. await adminClient.query(gql`
  410. mutation {
  411. updateProduct(input: { id: "T_1", customFields: { validateString: "hello" } }) {
  412. id
  413. }
  414. }
  415. `);
  416. }, `The custom field 'validateString' value ['hello'] does not match the pattern [^[0-9][a-z]+$]`),
  417. );
  418. it(
  419. 'invalid string option',
  420. assertThrowsWithMessage(async () => {
  421. await adminClient.query(gql`
  422. mutation {
  423. updateProduct(input: { id: "T_1", customFields: { stringWithOptions: "tiny" } }) {
  424. id
  425. }
  426. }
  427. `);
  428. }, `The custom field 'stringWithOptions' value ['tiny'] is invalid. Valid options are ['small', 'medium', 'large']`),
  429. );
  430. it('valid string option', async () => {
  431. const { updateProduct } = await adminClient.query(gql`
  432. mutation {
  433. updateProduct(input: { id: "T_1", customFields: { stringWithOptions: "medium" } }) {
  434. id
  435. customFields {
  436. stringWithOptions
  437. }
  438. }
  439. }
  440. `);
  441. expect(updateProduct.customFields.stringWithOptions).toBe('medium');
  442. });
  443. it(
  444. 'invalid localeString',
  445. assertThrowsWithMessage(async () => {
  446. await adminClient.query(gql`
  447. mutation {
  448. updateProduct(
  449. input: {
  450. id: "T_1"
  451. translations: [
  452. {
  453. id: "T_1"
  454. languageCode: en
  455. customFields: { validateLocaleString: "servus" }
  456. }
  457. ]
  458. }
  459. ) {
  460. id
  461. }
  462. }
  463. `);
  464. }, `The custom field 'validateLocaleString' value ['servus'] does not match the pattern [^[0-9][a-z]+$]`),
  465. );
  466. it(
  467. 'invalid int',
  468. assertThrowsWithMessage(async () => {
  469. await adminClient.query(gql`
  470. mutation {
  471. updateProduct(input: { id: "T_1", customFields: { validateInt: 12 } }) {
  472. id
  473. }
  474. }
  475. `);
  476. }, `The custom field 'validateInt' value [12] is greater than the maximum [10]`),
  477. );
  478. it(
  479. 'invalid float',
  480. assertThrowsWithMessage(async () => {
  481. await adminClient.query(gql`
  482. mutation {
  483. updateProduct(input: { id: "T_1", customFields: { validateFloat: 10.6 } }) {
  484. id
  485. }
  486. }
  487. `);
  488. }, `The custom field 'validateFloat' value [10.6] is greater than the maximum [10.5]`),
  489. );
  490. it(
  491. 'invalid datetime',
  492. assertThrowsWithMessage(async () => {
  493. await adminClient.query(gql`
  494. mutation {
  495. updateProduct(
  496. input: {
  497. id: "T_1"
  498. customFields: { validateDateTime: "2019-01-01T05:25:00.000Z" }
  499. }
  500. ) {
  501. id
  502. }
  503. }
  504. `);
  505. }, `The custom field 'validateDateTime' value [2019-01-01T05:25:00.000Z] is less than the minimum [2019-01-01T08:30]`),
  506. );
  507. it(
  508. 'invalid validate function with string',
  509. assertThrowsWithMessage(async () => {
  510. await adminClient.query(gql`
  511. mutation {
  512. updateProduct(input: { id: "T_1", customFields: { validateFn1: "invalid" } }) {
  513. id
  514. }
  515. }
  516. `);
  517. }, `The value ['invalid'] is not valid`),
  518. );
  519. it(
  520. 'invalid validate function with localized string',
  521. assertThrowsWithMessage(async () => {
  522. await adminClient.query(gql`
  523. mutation {
  524. updateProduct(input: { id: "T_1", customFields: { validateFn2: "invalid" } }) {
  525. id
  526. }
  527. }
  528. `);
  529. }, `The value ['invalid'] is not valid`),
  530. );
  531. it(
  532. 'invalid list field',
  533. assertThrowsWithMessage(async () => {
  534. await adminClient.query(gql`
  535. mutation {
  536. updateProduct(
  537. input: { id: "T_1", customFields: { intListWithValidation: [1, 2, 3] } }
  538. ) {
  539. id
  540. }
  541. }
  542. `);
  543. }, `Must include the number 42!`),
  544. );
  545. it('valid list field', async () => {
  546. const { updateProduct } = await adminClient.query(gql`
  547. mutation {
  548. updateProduct(input: { id: "T_1", customFields: { intListWithValidation: [1, 42, 3] } }) {
  549. id
  550. customFields {
  551. intListWithValidation
  552. }
  553. }
  554. }
  555. `);
  556. expect(updateProduct.customFields.intListWithValidation).toEqual([1, 42, 3]);
  557. });
  558. it('can inject providers into validation fn', async () => {
  559. const { updateProduct } = await adminClient.query(gql`
  560. mutation {
  561. updateProduct(input: { id: "T_1", customFields: { validateFn3: "some value" } }) {
  562. id
  563. customFields {
  564. validateFn3
  565. }
  566. }
  567. }
  568. `);
  569. expect(updateProduct.customFields.validateFn3).toBe('some value');
  570. expect(validateInjectorSpy).toHaveBeenCalledTimes(1);
  571. expect(validateInjectorSpy.mock.calls[0][0] instanceof TransactionalConnection).toBe(true);
  572. });
  573. it(
  574. 'supports async validation fn',
  575. assertThrowsWithMessage(async () => {
  576. await adminClient.query(gql`
  577. mutation {
  578. updateProduct(input: { id: "T_1", customFields: { validateFn4: "some value" } }) {
  579. id
  580. customFields {
  581. validateFn4
  582. }
  583. }
  584. }
  585. `);
  586. }, `async error`),
  587. );
  588. // https://github.com/vendure-ecommerce/vendure/issues/1000
  589. it(
  590. 'supports validation of relation types',
  591. assertThrowsWithMessage(async () => {
  592. await adminClient.query(gql`
  593. mutation {
  594. updateProduct(input: { id: "T_1", customFields: { validateRelationId: "T_1" } }) {
  595. id
  596. customFields {
  597. validateFn4
  598. }
  599. }
  600. }
  601. `);
  602. }, `relation error`),
  603. );
  604. });
  605. describe('public access', () => {
  606. it(
  607. 'non-public throws for Shop API',
  608. assertThrowsWithMessage(async () => {
  609. await shopClient.query(gql`
  610. query {
  611. product(id: "T_1") {
  612. id
  613. customFields {
  614. nonPublic
  615. }
  616. }
  617. }
  618. `);
  619. }, `Cannot query field "nonPublic" on type "ProductCustomFields"`),
  620. );
  621. it('publicly accessible via Shop API', async () => {
  622. const { product } = await shopClient.query(gql`
  623. query {
  624. product(id: "T_1") {
  625. id
  626. customFields {
  627. public
  628. }
  629. }
  630. }
  631. `);
  632. expect(product.customFields.public).toBe('ho!');
  633. });
  634. it(
  635. 'internal throws for Shop API',
  636. assertThrowsWithMessage(async () => {
  637. await shopClient.query(gql`
  638. query {
  639. product(id: "T_1") {
  640. id
  641. customFields {
  642. internalString
  643. }
  644. }
  645. }
  646. `);
  647. }, `Cannot query field "internalString" on type "ProductCustomFields"`),
  648. );
  649. it(
  650. 'internal throws for Admin API',
  651. assertThrowsWithMessage(async () => {
  652. await adminClient.query(gql`
  653. query {
  654. product(id: "T_1") {
  655. id
  656. customFields {
  657. internalString
  658. }
  659. }
  660. }
  661. `);
  662. }, `Cannot query field "internalString" on type "ProductCustomFields"`),
  663. );
  664. });
  665. describe('sort & filter', () => {
  666. it('can sort by custom fields', async () => {
  667. const { products } = await adminClient.query(gql`
  668. query {
  669. products(options: { sort: { nullable: ASC } }) {
  670. totalItems
  671. }
  672. }
  673. `);
  674. expect(products.totalItems).toBe(1);
  675. });
  676. it('can filter by custom fields', async () => {
  677. const { products } = await adminClient.query(gql`
  678. query {
  679. products(options: { filter: { stringWithDefault: { contains: "hello" } } }) {
  680. totalItems
  681. }
  682. }
  683. `);
  684. expect(products.totalItems).toBe(1);
  685. });
  686. it(
  687. 'cannot filter by internal field in Admin API',
  688. assertThrowsWithMessage(async () => {
  689. await adminClient.query(gql`
  690. query {
  691. products(options: { filter: { internalString: { contains: "hello" } } }) {
  692. totalItems
  693. }
  694. }
  695. `);
  696. }, `Field "internalString" is not defined by type "ProductFilterParameter"`),
  697. );
  698. it(
  699. 'cannot filter by internal field in Shop API',
  700. assertThrowsWithMessage(async () => {
  701. await shopClient.query(gql`
  702. query {
  703. products(options: { filter: { internalString: { contains: "hello" } } }) {
  704. totalItems
  705. }
  706. }
  707. `);
  708. }, `Field "internalString" is not defined by type "ProductFilterParameter"`),
  709. );
  710. });
  711. });