custom-fields.e2e-spec.ts 26 KB

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