1
0

custom-fields.e2e-spec.ts 20 KB

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