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

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