custom-field-struct.e2e-spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { mergeConfig } from '@vendure/core';
  2. import { createTestEnvironment } from '@vendure/testing';
  3. import gql from 'graphql-tag';
  4. import path from 'path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  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. const customConfig = mergeConfig(testConfig(), {
  12. dbConnectionOptions: {
  13. timezone: 'Z',
  14. },
  15. customFields: {
  16. Product: [
  17. {
  18. name: 'attributes',
  19. type: 'struct',
  20. fields: [
  21. { name: 'color', type: 'string' },
  22. { name: 'size', type: 'string' },
  23. { name: 'material', type: 'string' },
  24. { name: 'weight', type: 'int' },
  25. { name: 'isDownloadable', type: 'boolean' },
  26. { name: 'releaseDate', type: 'datetime' },
  27. ],
  28. },
  29. ],
  30. Customer: [
  31. {
  32. name: 'coupons',
  33. type: 'struct',
  34. list: true,
  35. fields: [
  36. { name: 'code', type: 'string' },
  37. { name: 'discount', type: 'int' },
  38. { name: 'used', type: 'boolean' },
  39. ],
  40. },
  41. {
  42. name: 'company',
  43. type: 'struct',
  44. fields: [{ name: 'phoneNumbers', type: 'string', list: true }],
  45. },
  46. {
  47. name: 'withValidation',
  48. type: 'struct',
  49. fields: [
  50. { name: 'stringWithPattern', type: 'string', pattern: '^[0-9][a-z]+$' },
  51. { name: 'numberWithRange', type: 'int', min: 1, max: 10 },
  52. {
  53. name: 'stringWithValidationFn',
  54. type: 'string',
  55. validate: value => {
  56. if (value !== 'valid') {
  57. return `The value ['${value as string}'] is not valid`;
  58. }
  59. },
  60. },
  61. ],
  62. },
  63. ],
  64. OrderLine: [
  65. {
  66. type: 'struct',
  67. name: 'fromBundle',
  68. fields: [
  69. { name: 'bundleId', type: 'string' },
  70. { name: 'bundleName', type: 'string' },
  71. ],
  72. },
  73. ],
  74. Address: [
  75. {
  76. name: 'geoLocation',
  77. type: 'struct',
  78. fields: [
  79. { name: 'latitude', type: 'float' },
  80. { name: 'longitude', type: 'float' },
  81. ],
  82. },
  83. ],
  84. // https://github.com/vendurehq/vendure/issues/3381
  85. GlobalSettings: [
  86. {
  87. name: 'tipsPercentage',
  88. type: 'struct',
  89. list: true,
  90. fields: [
  91. { name: 'percentage', type: 'float' },
  92. { name: 'name', type: 'string' },
  93. { name: 'isDefault', type: 'boolean' },
  94. ],
  95. },
  96. ],
  97. },
  98. });
  99. describe('Custom field struct type', () => {
  100. const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
  101. beforeAll(async () => {
  102. await server.init({
  103. initialData,
  104. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  105. customerCount: 1,
  106. });
  107. await adminClient.asSuperAdmin();
  108. }, TEST_SETUP_TIMEOUT_MS);
  109. afterAll(async () => {
  110. await server.destroy();
  111. });
  112. it('globalSettings.serverConfig.customFieldConfig resolves struct fields', async () => {
  113. const { globalSettings } = await adminClient.query(gql`
  114. query {
  115. globalSettings {
  116. serverConfig {
  117. customFieldConfig {
  118. Product {
  119. ... on CustomField {
  120. name
  121. type
  122. list
  123. }
  124. ... on StructCustomFieldConfig {
  125. fields {
  126. ... on StructField {
  127. name
  128. type
  129. list
  130. }
  131. }
  132. }
  133. }
  134. }
  135. }
  136. }
  137. }
  138. `);
  139. expect(globalSettings.serverConfig.customFieldConfig.Product).toEqual([
  140. {
  141. name: 'attributes',
  142. type: 'struct',
  143. list: false,
  144. fields: [
  145. { name: 'color', type: 'string', list: false },
  146. { name: 'size', type: 'string', list: false },
  147. { name: 'material', type: 'string', list: false },
  148. { name: 'weight', type: 'int', list: false },
  149. { name: 'isDownloadable', type: 'boolean', list: false },
  150. { name: 'releaseDate', type: 'datetime', list: false },
  151. ],
  152. },
  153. ]);
  154. });
  155. it('globalSettings.serverConfig.entityCustomFields resolves struct fields', async () => {
  156. const { globalSettings } = await adminClient.query(gql`
  157. query {
  158. globalSettings {
  159. serverConfig {
  160. entityCustomFields {
  161. entityName
  162. customFields {
  163. ... on CustomField {
  164. name
  165. type
  166. list
  167. }
  168. ... on StructCustomFieldConfig {
  169. fields {
  170. ... on StructField {
  171. name
  172. type
  173. list
  174. }
  175. }
  176. }
  177. }
  178. }
  179. }
  180. }
  181. }
  182. `);
  183. const productEntry = globalSettings.serverConfig.entityCustomFields.find(
  184. (e: any) => e.entityName === 'Product',
  185. );
  186. expect(productEntry).toEqual({
  187. entityName: 'Product',
  188. customFields: [
  189. {
  190. name: 'attributes',
  191. type: 'struct',
  192. list: false,
  193. fields: [
  194. { name: 'color', type: 'string', list: false },
  195. { name: 'size', type: 'string', list: false },
  196. { name: 'material', type: 'string', list: false },
  197. { name: 'weight', type: 'int', list: false },
  198. { name: 'isDownloadable', type: 'boolean', list: false },
  199. { name: 'releaseDate', type: 'datetime', list: false },
  200. ],
  201. },
  202. ],
  203. });
  204. });
  205. it('struct fields initially null', async () => {
  206. const result = await adminClient.query(gql`
  207. query {
  208. product(id: "T_1") {
  209. id
  210. customFields {
  211. attributes {
  212. color
  213. size
  214. material
  215. weight
  216. isDownloadable
  217. releaseDate
  218. }
  219. }
  220. }
  221. }
  222. `);
  223. expect(result.product.customFields.attributes).toEqual({
  224. color: null,
  225. size: null,
  226. material: null,
  227. weight: null,
  228. isDownloadable: null,
  229. releaseDate: null,
  230. });
  231. });
  232. it('update all fields in struct', async () => {
  233. const result = await adminClient.query(gql`
  234. mutation {
  235. updateProduct(
  236. input: {
  237. id: "T_1"
  238. customFields: {
  239. attributes: {
  240. color: "red"
  241. size: "L"
  242. material: "cotton"
  243. weight: 123
  244. isDownloadable: true
  245. releaseDate: "2021-01-01T12:00:00.000Z"
  246. }
  247. }
  248. }
  249. ) {
  250. id
  251. customFields {
  252. attributes {
  253. color
  254. size
  255. material
  256. weight
  257. isDownloadable
  258. releaseDate
  259. }
  260. }
  261. }
  262. }
  263. `);
  264. expect(result.updateProduct.customFields.attributes).toEqual({
  265. color: 'red',
  266. size: 'L',
  267. material: 'cotton',
  268. weight: 123,
  269. isDownloadable: true,
  270. releaseDate: '2021-01-01T12:00:00.000Z',
  271. });
  272. });
  273. it('partial update of struct fields nulls missing fields', async () => {
  274. const result = await adminClient.query(gql`
  275. mutation {
  276. updateProduct(
  277. input: {
  278. id: "T_1"
  279. customFields: { attributes: { color: "red", size: "L", material: "cotton" } }
  280. }
  281. ) {
  282. id
  283. customFields {
  284. attributes {
  285. color
  286. size
  287. material
  288. weight
  289. isDownloadable
  290. releaseDate
  291. }
  292. }
  293. }
  294. }
  295. `);
  296. expect(result.updateProduct.customFields.attributes).toEqual({
  297. color: 'red',
  298. size: 'L',
  299. material: 'cotton',
  300. weight: null,
  301. isDownloadable: null,
  302. releaseDate: null,
  303. });
  304. });
  305. it('updating OrderLine custom fields', async () => {
  306. const result = await shopClient.query(gql`
  307. mutation {
  308. addItemToOrder(
  309. productVariantId: "T_1"
  310. quantity: 1
  311. customFields: { fromBundle: { bundleId: "bundle-1", bundleName: "Bundle 1" } }
  312. ) {
  313. ... on Order {
  314. id
  315. lines {
  316. id
  317. customFields {
  318. fromBundle {
  319. bundleId
  320. bundleName
  321. }
  322. }
  323. }
  324. }
  325. }
  326. }
  327. `);
  328. expect(result.addItemToOrder.lines[0].customFields).toEqual({
  329. fromBundle: {
  330. bundleId: 'bundle-1',
  331. bundleName: 'Bundle 1',
  332. },
  333. });
  334. });
  335. it('updating Address custom fields', async () => {
  336. const result = await adminClient.query(gql`
  337. mutation {
  338. updateCustomerAddress(
  339. input: { id: "T_1", customFields: { geoLocation: { latitude: 1.23, longitude: 4.56 } } }
  340. ) {
  341. id
  342. customFields {
  343. geoLocation {
  344. latitude
  345. longitude
  346. }
  347. }
  348. }
  349. }
  350. `);
  351. expect(result.updateCustomerAddress.customFields).toEqual({
  352. geoLocation: {
  353. latitude: 1.23,
  354. longitude: 4.56,
  355. },
  356. });
  357. });
  358. it('updating OrderAddress custom fields', async () => {
  359. const result = await shopClient.query(
  360. gql`
  361. mutation SetShippingAddress($input: CreateAddressInput!) {
  362. setOrderShippingAddress(input: $input) {
  363. ... on Order {
  364. id
  365. shippingAddress {
  366. customFields {
  367. geoLocation {
  368. latitude
  369. longitude
  370. }
  371. }
  372. }
  373. }
  374. ... on ErrorResult {
  375. errorCode
  376. message
  377. }
  378. }
  379. }
  380. `,
  381. {
  382. input: {
  383. fullName: 'name',
  384. streetLine1: '12 the street',
  385. city: 'foo',
  386. postalCode: '123456',
  387. countryCode: 'US',
  388. customFields: {
  389. geoLocation: {
  390. latitude: 1.23,
  391. longitude: 4.56,
  392. },
  393. },
  394. },
  395. },
  396. );
  397. expect(result.setOrderShippingAddress.shippingAddress.customFields).toEqual({
  398. geoLocation: {
  399. latitude: 1.23,
  400. longitude: 4.56,
  401. },
  402. });
  403. });
  404. describe('struct list', () => {
  405. it('is initially an empty array', async () => {
  406. const result = await adminClient.query(gql`
  407. query {
  408. customer(id: "T_1") {
  409. customFields {
  410. coupons {
  411. code
  412. discount
  413. used
  414. }
  415. }
  416. }
  417. }
  418. `);
  419. expect(result.customer.customFields.coupons).toEqual([]);
  420. });
  421. it('sets list values', async () => {
  422. const result = await adminClient.query(gql`
  423. mutation {
  424. updateCustomer(
  425. input: {
  426. id: "T_1"
  427. customFields: {
  428. coupons: [
  429. { code: "ABC", discount: 10, used: false }
  430. { code: "DEF", discount: 20, used: true }
  431. ]
  432. }
  433. }
  434. ) {
  435. ... on Customer {
  436. id
  437. customFields {
  438. coupons {
  439. code
  440. discount
  441. used
  442. }
  443. }
  444. }
  445. }
  446. }
  447. `);
  448. expect(result.updateCustomer.customFields).toEqual({
  449. coupons: [
  450. { code: 'ABC', discount: 10, used: false },
  451. { code: 'DEF', discount: 20, used: true },
  452. ],
  453. });
  454. });
  455. });
  456. describe('struct field list', () => {
  457. it('is initially an empty array', async () => {
  458. const result = await adminClient.query(gql`
  459. query {
  460. customer(id: "T_1") {
  461. id
  462. customFields {
  463. company {
  464. phoneNumbers
  465. }
  466. }
  467. }
  468. }
  469. `);
  470. expect(result.customer.customFields.company).toEqual({
  471. phoneNumbers: [],
  472. });
  473. });
  474. it('set list field values', async () => {
  475. const result = await adminClient.query(gql`
  476. mutation {
  477. updateCustomer(
  478. input: { id: "T_1", customFields: { company: { phoneNumbers: ["123", "456"] } } }
  479. ) {
  480. ... on Customer {
  481. id
  482. customFields {
  483. company {
  484. phoneNumbers
  485. }
  486. }
  487. }
  488. }
  489. }
  490. `);
  491. expect(result.updateCustomer.customFields.company).toEqual({
  492. phoneNumbers: ['123', '456'],
  493. });
  494. });
  495. });
  496. describe('struct field validation', () => {
  497. it(
  498. 'string pattern',
  499. assertThrowsWithMessage(async () => {
  500. await adminClient.query(gql`
  501. mutation {
  502. updateCustomer(
  503. input: {
  504. id: "T_1"
  505. customFields: { withValidation: { stringWithPattern: "abc" } }
  506. }
  507. ) {
  508. ... on Customer {
  509. id
  510. }
  511. }
  512. }
  513. `);
  514. }, `The custom field "stringWithPattern" value ["abc"] does not match the pattern [^[0-9][a-z]+$]`),
  515. );
  516. it(
  517. 'number range',
  518. assertThrowsWithMessage(async () => {
  519. await adminClient.query(gql`
  520. mutation {
  521. updateCustomer(
  522. input: { id: "T_1", customFields: { withValidation: { numberWithRange: 15 } } }
  523. ) {
  524. ... on Customer {
  525. id
  526. }
  527. }
  528. }
  529. `);
  530. }, `The custom field "numberWithRange" value [15] is greater than the maximum [10]`),
  531. );
  532. it(
  533. 'validate function',
  534. assertThrowsWithMessage(async () => {
  535. await adminClient.query(gql`
  536. mutation {
  537. updateCustomer(
  538. input: {
  539. id: "T_1"
  540. customFields: { withValidation: { stringWithValidationFn: "bad" } }
  541. }
  542. ) {
  543. ... on Customer {
  544. id
  545. }
  546. }
  547. }
  548. `);
  549. }, `The value ['bad'] is not valid`),
  550. );
  551. });
  552. });