custom-fields.e2e-spec.ts 22 KB

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