custom-fields.e2e-spec.ts 20 KB

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