custom-fields.e2e-spec.ts 24 KB

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