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

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