custom-fields.e2e-spec.ts 20 KB

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