richie-education 2.28.2-dev26 → 2.28.2-dev53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/js/api/joanie.ts +42 -17
  2. package/js/api/lms/dummy.ts +1 -12
  3. package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +16 -9
  4. package/js/components/ContractFrame/AbstractContractFrame.tsx +28 -23
  5. package/js/components/ContractFrame/LearnerContractFrame.tsx +2 -2
  6. package/js/components/ContractFrame/_styles.scss +6 -14
  7. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +15 -45
  8. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +17 -24
  9. package/js/components/DownloadContractButton/index.spec.tsx +1 -1
  10. package/js/components/OpenEdxFullNameForm/index.spec.tsx +229 -0
  11. package/js/components/OpenEdxFullNameForm/index.tsx +7 -7
  12. package/js/components/PaymentInterfaces/LyraPopIn.tsx +2 -2
  13. package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
  14. package/js/components/PaymentInterfaces/__mocks__/index.tsx +1 -4
  15. package/js/components/PaymentInterfaces/types.ts +5 -2
  16. package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
  17. package/js/components/PaymentScheduleGrid/index.tsx +50 -70
  18. package/js/components/PurchaseButton/index.spec.tsx +84 -37
  19. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +2 -1
  20. package/js/components/SaleTunnel/CertificateSaleTunnel/index.tsx +2 -2
  21. package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +6 -10
  22. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +80 -27
  23. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +16 -20
  24. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/_styles.scss +12 -0
  25. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +160 -0
  26. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +15 -29
  27. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
  28. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +39 -11
  29. package/js/components/SaleTunnel/SubscriptionButton/_styles.scss +7 -0
  30. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +201 -0
  31. package/js/components/SaleTunnel/_styles.scss +16 -5
  32. package/js/components/SaleTunnel/hooks/useTerms.tsx +0 -77
  33. package/js/components/SaleTunnel/index.credential.spec.tsx +14 -25
  34. package/js/components/SaleTunnel/index.full-process.spec.tsx +116 -48
  35. package/js/components/SaleTunnel/index.spec.tsx +334 -717
  36. package/js/components/SignContractButton/index.omniscientOrders.spec.tsx +16 -11
  37. package/js/components/SignContractButton/index.spec.tsx +16 -20
  38. package/js/components/SignContractButton/index.tsx +3 -1
  39. package/js/hooks/useCreditCards/index.spec.tsx +70 -6
  40. package/js/hooks/useCreditCards/index.ts +49 -11
  41. package/js/hooks/useOrders/index.spec.tsx +322 -0
  42. package/js/hooks/{useOrders.ts → useOrders/index.ts} +40 -14
  43. package/js/hooks/usePaymentSchedule.tsx +23 -0
  44. package/js/hooks/useProductOrder/index.spec.tsx +77 -60
  45. package/js/hooks/useProductOrder/index.tsx +2 -2
  46. package/js/hooks/useResources/useResourcesRoot.ts +4 -3
  47. package/js/index.tsx +2 -0
  48. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.spec.tsx +1 -1
  49. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.tsx +4 -2
  50. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +8 -5
  51. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +8 -9
  52. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +1 -1
  53. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +1 -6
  54. package/js/settings/settings.test.ts +11 -2
  55. package/js/types/Joanie.ts +77 -31
  56. package/js/utils/OrderHelper/index.ts +47 -38
  57. package/js/utils/test/factories/joanie.ts +66 -68
  58. package/js/widgets/Dashboard/components/DashboardItem/CourseEnrolling/index.tsx +8 -18
  59. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +26 -32
  60. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +11 -6
  61. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +114 -5
  62. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +99 -12
  63. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +3 -1
  64. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +6 -7
  65. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
  66. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +126 -0
  67. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +209 -0
  68. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +18 -71
  69. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +40 -25
  70. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +28 -22
  71. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +18 -73
  72. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +32 -16
  73. package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +3 -11
  74. package/js/widgets/Dashboard/components/Signature/SignatureDummy.tsx +25 -3
  75. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/EnrollableCourseRunList.tsx +2 -6
  76. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +7 -14
  77. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.spec.tsx +7 -5
  78. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.tsx +5 -7
  79. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +242 -332
  80. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +12 -13
  81. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +10 -21
  82. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +2 -2
  83. package/package.json +2 -1
  84. package/scss/components/_index.scss +4 -2
  85. package/js/components/PaymentButton/_styles.scss +0 -27
  86. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +0 -338
  87. package/js/components/SaleTunnel/SaleTunnelNotValidated/index.tsx +0 -70
  88. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader/index.tsx +0 -41
  89. /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
@@ -1,35 +1,38 @@
1
- import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
1
+ import { act, cleanup, screen, waitFor } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
  import queryString from 'query-string';
4
4
  import userEvent from '@testing-library/user-event';
5
- import { OrderState, Product, ProductType } from 'types/Joanie';
5
+ import { within } from '@testing-library/dom';
6
+ import { createIntl } from 'react-intl';
7
+ import { useState } from 'react';
8
+ import { OrderState, Product, ProductType, NOT_CANCELED_ORDER_STATES } from 'types/Joanie';
9
+ import {
10
+ RichieContextFactory as mockRichieContextFactory,
11
+ UserFactory,
12
+ PacedCourseFactory,
13
+ } from 'utils/test/factories/richie';
6
14
  import {
7
15
  AddressFactory,
8
- CertificateOrderWithOneClickPaymentFactory,
9
- CertificateOrderWithPaymentFactory,
16
+ CertificateOrderFactory,
10
17
  CertificateProductFactory,
11
- CredentialOrderWithOneClickPaymentFactory,
12
- CredentialOrderWithPaymentFactory,
18
+ CredentialOrderFactory,
13
19
  CredentialProductFactory,
14
20
  CreditCardFactory,
15
21
  EnrollmentFactory,
22
+ PaymentInstallmentFactory,
16
23
  } from 'utils/test/factories/joanie';
17
- import {
18
- RichieContextFactory as mockRichieContextFactory,
19
- PacedCourseFactory,
20
- UserFactory,
21
- } from 'utils/test/factories/richie';
22
24
  import { render } from 'utils/test/render';
23
25
  import { SaleTunnel, SaleTunnelProps } from 'components/SaleTunnel/index';
24
26
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
25
- import { HttpStatusCode } from 'utils/errors/HttpError';
27
+ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
26
28
  import { getAddressLabel } from 'components/SaleTunnel/AddressSelector';
27
- import { ObjectHelper } from 'utils/ObjectHelper';
28
- import { PAYMENT_SETTINGS } from 'settings';
29
29
  import { User } from 'types/User';
30
30
  import { OpenEdxApiProfile } from 'types/openEdx';
31
31
  import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
32
32
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
33
+ import { StringHelper } from 'utils/StringHelper';
34
+ import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
35
+ import { Deferred } from 'utils/test/deferred';
33
36
 
34
37
  jest.mock('utils/context', () => ({
35
38
  __esModule: true,
@@ -50,654 +53,322 @@ jest.mock('utils/indirection/window', () => ({
50
53
  }),
51
54
  }));
52
55
 
53
- jest.mock('../PaymentInterfaces');
56
+ jest.mock('./SaleTunnelSavePaymentMethod', () => ({
57
+ __esModule: true,
58
+ default: () => <div data-testid="sale-tunnel-save-payment-method-step" />,
59
+ }));
60
+ jest.mock('components/ContractFrame/LearnerContractFrame', () => ({
61
+ __esModule: true,
62
+ default: () => <div data-testid="sale-tunnel-sign-step" />,
63
+ }));
54
64
 
55
65
  describe.each([
56
66
  {
57
67
  productType: ProductType.CREDENTIAL,
58
68
  ProductFactory: CredentialProductFactory,
59
- OrderWithOneClickPaymentFactory: CredentialOrderWithOneClickPaymentFactory,
60
- OrderWithPaymentFactory: CredentialOrderWithPaymentFactory,
69
+ OrderFactory: CredentialOrderFactory,
61
70
  },
62
71
  {
63
72
  productType: ProductType.CERTIFICATE,
64
73
  ProductFactory: CertificateProductFactory,
65
- OrderWithOneClickPaymentFactory: CertificateOrderWithOneClickPaymentFactory,
66
- OrderWithPaymentFactory: CertificateOrderWithPaymentFactory,
74
+ OrderFactory: CertificateOrderFactory,
67
75
  },
68
- ])(
69
- 'SaleTunnel for $productType product',
70
- ({ productType, ProductFactory, OrderWithOneClickPaymentFactory, OrderWithPaymentFactory }) => {
71
- let nbApiCalls: number;
72
-
73
- const course = PacedCourseFactory().one();
74
- const enrollment =
75
- productType === ProductType.CERTIFICATE ? EnrollmentFactory().one() : undefined;
76
-
77
- let richieUser: User;
78
- let openApiEdxProfile: OpenEdxApiProfile;
79
-
80
- const formatPrice = (price: number, currency: string) =>
81
- new Intl.NumberFormat('en', {
82
- currency,
83
- style: 'currency',
84
- }).format(price);
85
-
86
- const Wrapper = (props: Omit<SaleTunnelProps, 'isOpen' | 'onClose'>) => {
87
- return (
88
- <SaleTunnel
89
- {...props}
90
- enrollment={enrollment}
91
- course={productType === ProductType.CREDENTIAL ? course : undefined}
92
- isOpen={true}
93
- onClose={() => {}}
94
- />
95
- );
96
- };
97
-
98
- beforeEach(() => {
99
- jest.useFakeTimers();
100
- jest.clearAllTimers();
101
- jest.resetAllMocks();
102
-
103
- fetchMock.restore();
104
- sessionStorage.clear();
105
-
106
- nbApiCalls = 3;
107
-
108
- richieUser = UserFactory().one();
109
- openApiEdxProfile = OpenEdxApiProfileFactory({
110
- username: richieUser.username,
111
- email: richieUser.email,
112
- name: richieUser.full_name,
113
- }).one();
114
-
115
- const { 'pref-lang': prefLang, ...openEdxAccount } = openApiEdxProfile;
116
-
117
- fetchMock.get(
118
- `https://auth.test/api/user/v1/accounts/${richieUser.username}`,
119
- openEdxAccount,
120
- );
121
- fetchMock.get(`https://auth.test/api/user/v1/preferences/${richieUser.username}`, {
122
- 'pref-lang': prefLang,
123
- });
76
+ ])('SaleTunnel for $productType product', ({ productType, ProductFactory, OrderFactory }) => {
77
+ let nbApiCalls: number;
78
+
79
+ const course = PacedCourseFactory().one();
80
+ const enrollment =
81
+ productType === ProductType.CERTIFICATE
82
+ ? EnrollmentFactory({ course_run: { course } }).one()
83
+ : undefined;
84
+
85
+ let richieUser: User;
86
+ let openApiEdxProfile: OpenEdxApiProfile;
87
+
88
+ const formatPrice = (price: number, currency: string) =>
89
+ new Intl.NumberFormat('en', {
90
+ currency,
91
+ style: 'currency',
92
+ }).format(price);
93
+
94
+ const Wrapper = (props: Omit<SaleTunnelProps, 'isOpen' | 'onClose'>) => {
95
+ const [open, setOpen] = useState(true);
96
+ return (
97
+ <SaleTunnel
98
+ {...props}
99
+ enrollment={enrollment}
100
+ course={productType === ProductType.CREDENTIAL ? course : undefined}
101
+ isOpen={open}
102
+ onClose={() => setOpen(false)}
103
+ />
104
+ );
105
+ };
106
+
107
+ beforeEach(() => {
108
+ jest.useFakeTimers();
109
+ jest.clearAllTimers();
110
+ jest.resetAllMocks();
111
+
112
+ fetchMock.restore();
113
+ sessionStorage.clear();
114
+
115
+ nbApiCalls = 3;
116
+
117
+ richieUser = UserFactory().one();
118
+ openApiEdxProfile = OpenEdxApiProfileFactory({
119
+ username: richieUser.username,
120
+ email: richieUser.email,
121
+ name: richieUser.full_name,
122
+ }).one();
123
+
124
+ const { 'pref-lang': prefLang, ...openEdxAccount } = openApiEdxProfile;
125
+
126
+ fetchMock.get(`https://auth.test/api/user/v1/accounts/${richieUser.username}`, openEdxAccount);
127
+ fetchMock.get(`https://auth.test/api/user/v1/preferences/${richieUser.username}`, {
128
+ 'pref-lang': prefLang,
124
129
  });
130
+ });
125
131
 
126
- setupJoanieSession();
132
+ setupJoanieSession();
127
133
 
128
- afterEach(() => {
129
- act(() => {
130
- jest.runOnlyPendingTimers();
131
- });
132
- jest.useRealTimers();
133
- cleanup();
134
+ afterEach(() => {
135
+ act(() => {
136
+ jest.runOnlyPendingTimers();
134
137
  });
135
-
136
- const getFetchOrderQueryParams = (product: Product) => {
137
- return product.type === ProductType.CREDENTIAL
138
- ? {
139
- course_code: course.code,
140
- product_id: product.id,
141
- state: ['pending', 'validated', 'submitted'],
142
- }
143
- : {
144
- enrollment_id: enrollment?.id,
145
- product_id: product.id,
146
- state: ['pending', 'validated', 'submitted'],
147
- };
148
- };
149
-
150
- it('should render a payment button with a specific label when a credit card is provided', async () => {
151
- const product = ProductFactory().one();
152
- const creditCard = CreditCardFactory().one();
153
- const address = AddressFactory().one();
154
-
155
- fetchMock.get(`https://joanie.endpoint/api/v1.0/orders/`, [], {
156
- overwriteRoutes: true,
157
- });
158
- fetchMock.get(
138
+ jest.useRealTimers();
139
+ cleanup();
140
+ });
141
+
142
+ const getFetchOrderQueryParams = (product: Product) => {
143
+ return product.type === ProductType.CREDENTIAL
144
+ ? {
145
+ course_code: course.code,
146
+ product_id: product.id,
147
+ state: NOT_CANCELED_ORDER_STATES,
148
+ }
149
+ : {
150
+ enrollment_id: enrollment?.id,
151
+ product_id: product.id,
152
+ state: NOT_CANCELED_ORDER_STATES,
153
+ };
154
+ };
155
+
156
+ it('should create an order when the user clicks on subscribe button', async () => {
157
+ const product = ProductFactory().one();
158
+ const billingAddress = AddressFactory({
159
+ is_main: true,
160
+ }).one();
161
+ const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
162
+
163
+ fetchMock
164
+ .get(
159
165
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
160
166
  [],
161
- );
162
- fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', [address], {
163
- overwriteRoutes: true,
164
- });
165
- fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
167
+ )
168
+ .get(
169
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
170
+ [],
171
+ )
172
+ .post('https://joanie.endpoint/api/v1.0/orders/', order)
173
+ .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, order)
174
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
166
175
  overwriteRoutes: true,
167
176
  });
168
177
 
169
- render(<Wrapper product={product} />, {
170
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
171
- });
172
-
173
- const $button = (await screen.findByRole('button', {
174
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
175
- })) as HTMLButtonElement;
176
-
177
- // a billing address is missing, but the button stays enabled
178
- // this allows the user to get feedback on what's missing to make the payment by clicking on the button
179
- expect($button.disabled).toBe(false);
178
+ render(<Wrapper product={product} />, {
179
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
180
180
  });
181
+ nbApiCalls += 1; // useProductOrder call.
182
+ nbApiCalls += 1; // get user account call.
183
+ nbApiCalls += 1; // get user preferences call.
184
+ nbApiCalls += 1; // product payment-schedule call
185
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
181
186
 
182
- it('should create an order only the first time the payment interface is shown, and not after aborting', async () => {
183
- const product = ProductFactory().one();
184
- const billingAddress = AddressFactory({
185
- is_main: true,
186
- }).one();
187
- const { payment_info: paymentInfo, ...order } = OrderWithPaymentFactory().one();
188
-
189
- fetchMock
190
- .get(
191
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
192
- [],
193
- )
194
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
195
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
196
- paymentInfo,
197
- })
198
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, {
199
- ...order,
200
- })
201
- .post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/abort/`, HttpStatusCode.OK)
202
- .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
203
- overwriteRoutes: true,
204
- });
205
-
206
- render(<Wrapper product={product} />, {
207
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
208
- });
209
- nbApiCalls += 1; // useProductOrder call.
210
- nbApiCalls += 1; // get user account call.
211
- nbApiCalls += 1; // get user preferences call.
212
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
213
-
214
- const $terms = screen.getByLabelText(
215
- 'By checking this box, you accept the General Terms of Sale',
216
- );
217
- const user = userEvent.setup({ delay: null });
218
- await user.click($terms);
219
-
220
- const $button = screen.getByRole('button', {
221
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
222
- }) as HTMLButtonElement;
223
-
224
- // - Payment button should not be disabled.
225
- expect($button.disabled).toBe(false);
226
-
227
- // - wait for address to be loaded.
228
- await screen.findByText(getAddressLabel(billingAddress));
229
-
230
- // - Order should not have been submitted yet
231
- expect(
232
- fetchMock
233
- .calls()
234
- .filter(
235
- (call) => call[0] === `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`,
236
- ),
237
- ).toHaveLength(0);
238
-
239
- // - User clicks on pay button
240
- await user.click($button);
241
-
242
- // - Route to create order should have been called
243
- nbApiCalls += 1; // order post create (invalidate queries)
244
- nbApiCalls += 1; // order get (invalidate queries)
245
- nbApiCalls += 1; // useProductOrder call (invalidate from create)
246
- nbApiCalls += 1; // order submit (invalidate queries)
247
- nbApiCalls += 1; // order get (invalidate queries)
248
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
249
-
250
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
251
- expect(fetchMock.lastUrl()).toBe(
252
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
253
- );
187
+ const user = userEvent.setup({ delay: null });
254
188
 
255
- // - Order should have been submitted once
256
- expect(
257
- fetchMock
258
- .calls()
259
- .filter(
260
- (call) => call[0] === `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`,
261
- ),
262
- ).toHaveLength(1);
189
+ const $button = screen.getByRole<HTMLButtonElement>('button', {
190
+ name: `Subscribe`,
191
+ });
263
192
 
264
- // - Spinner should be displayed
265
- screen.getByText('Payment in progress');
193
+ // - wait for address to be loaded.
194
+ await screen.findByText(getAddressLabel(billingAddress));
266
195
 
267
- // - Payment interface should be displayed
268
- screen.getByText('Payment interface component');
196
+ // - Subscribe button should not be disabled.
197
+ expect($button.disabled).toBe(false);
269
198
 
270
- // - Simulate the payment aborting.
271
- fetchMock.get(
199
+ // - Order should not have been created yet
200
+ expect(
201
+ fetchMock
202
+ .calls()
203
+ .filter(
204
+ ([url, metadata]) =>
205
+ url === 'https://joanie.endpoint/api/v1.0/orders/' && metadata?.method === 'POST',
206
+ ),
207
+ ).toHaveLength(0);
208
+
209
+ // - User clicks on pay button
210
+ await user.click($button);
211
+
212
+ fetchMock.get(
213
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
214
+ [order],
215
+ { overwriteRoutes: true },
216
+ );
217
+
218
+ // - Route to create order should have been called
219
+ nbApiCalls += 1; // order create
220
+ nbApiCalls += 1; // order get (invalidate queries)
221
+ nbApiCalls += 1; // useProductOrder call (invalidate from create)
222
+
223
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
224
+ expect(fetchMock.lastUrl()).toBe(
225
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
226
+ );
227
+
228
+ // - Order should have been created once
229
+ expect(
230
+ fetchMock
231
+ .calls()
232
+ .filter(
233
+ ([url, metadata]) =>
234
+ url === 'https://joanie.endpoint/api/v1.0/orders/' && metadata?.method === 'POST',
235
+ ),
236
+ ).toHaveLength(1);
237
+
238
+ // - Spinner should be displayed
239
+ screen.getByText('Order creation in progress');
240
+
241
+ // - Save payment step should be displayed
242
+ await screen.findByTestId('sale-tunnel-save-payment-method-step');
243
+ });
244
+
245
+ it('should render an error message when order creation fails', async () => {
246
+ const product = ProductFactory().one();
247
+ const billingAddress = AddressFactory({
248
+ is_main: true,
249
+ }).one();
250
+ const deferred = new Deferred();
251
+
252
+ fetchMock
253
+ .get(
272
254
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
273
- [
274
- {
275
- ...order,
276
- state: OrderState.PENDING,
277
- },
278
- ],
279
- { overwriteRoutes: true },
280
- );
281
-
282
- await user.click(screen.getByTestId('payment-abort'));
283
-
284
- nbApiCalls += 1; // abort order.
285
- nbApiCalls += 1; // order get (invalidate queries)
286
- nbApiCalls += 1; // useProductOrder call (invalidate from create)
287
-
288
- await waitFor(() => {
289
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
255
+ [],
256
+ )
257
+ .get(
258
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
259
+ [],
260
+ )
261
+ .post('https://joanie.endpoint/api/v1.0/orders/', deferred.promise)
262
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
263
+ overwriteRoutes: true,
290
264
  });
291
- expect(fetchMock.calls()[fetchMock.calls().length - 3][0]).toBe(
292
- `https://joanie.endpoint/api/v1.0/orders/${order.id}/abort/`,
293
- );
294
- expect(fetchMock.calls()[fetchMock.calls().length - 1][0]).toBe(
295
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
296
- );
297
-
298
- screen.getByText('You have aborted the payment.');
299
-
300
- // - User clicks on pay button again.
301
- await user.click($button);
302
-
303
- // - Spinner should be displayed
304
- await screen.findByText('Payment in progress');
305
-
306
- // - Payment interface should be displayed
307
- await screen.findByText('Payment interface component');
308
-
309
- // - Now we make sure the order is not created again and just submitted.
310
- nbApiCalls += 1; // order submit (invalidate queries)
311
- nbApiCalls += 1; // order get (invalidate queries)
312
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
313
265
 
314
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
315
-
316
- // - Order should not have been re-submitted
317
- expect(
318
- fetchMock
319
- .calls()
320
- .filter(
321
- (call) => call[0] === `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`,
322
- ),
323
- ).toHaveLength(2);
324
- expect(fetchMock.lastUrl()).toBe(
325
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
326
- );
266
+ render(<Wrapper product={product} />, {
267
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
327
268
  });
269
+ nbApiCalls += 1; // useProductOrder get order with filters
270
+ nbApiCalls += 1; // get user account call.
271
+ nbApiCalls += 1; // get user preferences call.
272
+ nbApiCalls += 1; // get product payment schedule.
273
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
328
274
 
329
- it('should render a payment button and not call the order creation route when there is a pending order', async () => {
330
- const product = ProductFactory().one();
331
- const billingAddress = AddressFactory({
332
- is_main: true,
333
- }).one();
334
- const creditCard = CreditCardFactory().one();
335
- const { payment_info: paymentInfo, ...order } = OrderWithOneClickPaymentFactory().one();
275
+ const user = userEvent.setup({ delay: null });
336
276
 
337
- const initialOrder = {
338
- ...order,
339
- state: OrderState.PENDING,
340
- };
341
- const orderSubmitted = {
342
- ...order,
343
- state: OrderState.SUBMITTED,
344
- };
277
+ const $button = screen.getByRole('button', {
278
+ name: `Subscribe`,
279
+ }) as HTMLButtonElement;
345
280
 
346
- fetchMock
347
- .get(
348
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
349
- [initialOrder],
350
- )
351
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
352
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
353
- payment_info: paymentInfo,
354
- })
355
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, orderSubmitted)
356
- .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
357
- overwriteRoutes: true,
358
- })
359
- .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
360
- overwriteRoutes: true,
361
- });
281
+ // - wait for address to be loaded.
282
+ await screen.findByText(getAddressLabel(billingAddress));
362
283
 
363
- render(<Wrapper product={product} />, {
364
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
365
- });
366
- await waitFor(() => {
367
- expect(screen.getByTestId('payment-button-order-loaded')).toBeInTheDocument();
368
- });
284
+ // - As all information are provided, subscribe button should not be disabled.
285
+ expect($button.disabled).toBe(false);
369
286
 
370
- const user = userEvent.setup({ delay: null });
371
- const $terms = screen.getByLabelText(
372
- 'By checking this box, you accept the General Terms of Sale',
373
- );
374
- await user.click($terms);
287
+ // - User clicks on subscribe button
288
+ await user.click($button);
375
289
 
376
- nbApiCalls += 1; // useProductOrder get order with filters
377
- nbApiCalls += 1; // get user account call.
378
- nbApiCalls += 1; // get user preferences call.
379
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
380
- const $button = screen.getByRole('button', {
381
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
382
- }) as HTMLButtonElement;
290
+ // - Route to create order should have been called
291
+ nbApiCalls += 1; // order post create (no query invalidation)
383
292
 
384
- // - Payment button should not be disabled.
385
- expect($button.disabled).toBe(false);
293
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
386
294
 
387
- // - wait for address to be loaded.
388
- await screen.findByText(getAddressLabel(billingAddress));
295
+ // - Spinner should be displayed and subscribe button should be disabled
296
+ screen.getByText('Order creation in progress');
297
+ expect($button.disabled).toBe(true);
389
298
 
390
- // - User clicks on pay button
391
- fetchMock.get(
392
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
393
- [orderSubmitted],
394
- { overwriteRoutes: true },
395
- );
396
- await user.click($button);
397
-
398
- // - In real world condition the success callback is immediately called for one click payments.
399
- // - but here we need to click manually.
400
- const $success = await screen.findByTestId('payment-success');
401
- await user.click($success);
402
-
403
- // - Route to submit an existing order
404
- // - Furthermore, as payment succeeded immediately, order should have been refetched
405
- nbApiCalls += 1; // order submit
406
- nbApiCalls += 1; // order get (invalidate queries)
407
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
408
- nbApiCalls += 1; // order get on id
409
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
410
-
411
- const submitCall = fetchMock
412
- .calls()
413
- .find((call) => call[0] === `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`);
414
- expect(submitCall).not.toBeUndefined();
415
- expect(JSON.parse(submitCall![1]!.body as string)).toEqual({
416
- billing_address: ObjectHelper.omit(billingAddress, 'id', 'is_main'),
417
- credit_card_id: creditCard.id,
418
- });
419
- expect(fetchMock.lastUrl()).toBe(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`);
420
-
421
- // - Spinner should be displayed
422
- screen.getByText('Payment in progress');
423
-
424
- // - Order should be polled until its state is validated
425
- fetchMock.get(
426
- `https://joanie.endpoint/api/v1.0/orders/${order.id}/`,
427
- {
428
- ...order,
429
- state: OrderState.VALIDATED,
430
- },
431
- {
432
- overwriteRoutes: true,
433
- },
299
+ // - Simulate the order creation has failed
300
+ await act(async () => {
301
+ deferred.reject(
302
+ new HttpError(HttpStatusCode.BAD_REQUEST, 'An error occurred during order creation.'),
434
303
  );
435
-
436
- // - Advance timer to one tick
437
- await act(async () => {
438
- jest.runOnlyPendingTimers();
439
- });
440
-
441
- // - Order should have been refetched
442
- nbApiCalls += 1; // order get on id
443
- nbApiCalls += 1; // orders get (invalidate queries)
444
- nbApiCalls += 1; // useProductOrder call (invalidate from success)
445
- nbApiCalls += 1; // orders get (invalidate queries)
446
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
447
-
448
- // - As order state is validated, success step is displayed
449
- screen.getByTestId('generic-sale-tunnel-success-step');
450
- screen.getByText('Congratulations!');
451
- // - And poller should be stopped
452
- await act(async () => {
453
- jest.runOnlyPendingTimers();
454
- });
455
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
456
304
  });
457
305
 
458
- it('should render SaleTunnelNotValidated if order is not validated after a given delay', async () => {
459
- const product = ProductFactory().one();
460
- const billingAddress = AddressFactory({
461
- is_main: true,
462
- }).one();
463
- const creditCard = CreditCardFactory().one();
464
- const { payment_info: paymentInfo, ...order } = OrderWithOneClickPaymentFactory().one();
465
- const orderSubmitted = {
466
- ...order,
467
- state: OrderState.SUBMITTED,
468
- };
469
-
470
- fetchMock
471
- .get(
472
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
473
- [orderSubmitted],
474
- )
475
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
476
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
477
- payment_info: paymentInfo,
478
- })
479
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, orderSubmitted)
480
- .post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/abort/`, HttpStatusCode.OK)
481
- .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
482
- overwriteRoutes: true,
483
- })
484
- .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
485
- overwriteRoutes: true,
486
- });
306
+ // - An error message should be displayed
307
+ const $error = screen.getByText('An error occurred during order creation. Please retry later.');
308
+ expect(document.activeElement).toBe($error);
487
309
 
488
- render(<Wrapper product={product} />, {
489
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
490
- });
491
- await waitFor(() => {
492
- expect(screen.getByTestId('payment-button-order-loaded')).toBeInTheDocument();
493
- });
494
- nbApiCalls += 1; // fetcher order for userProductOrder
495
- nbApiCalls += 1; // get user account call.
496
- nbApiCalls += 1; // get user preferences call.
497
- const apiCalls = fetchMock.calls().map((call) => call[0]);
498
- expect(apiCalls).toContain(
499
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
500
- );
501
-
502
- const $terms = screen.getByLabelText(
503
- 'By checking this box, you accept the General Terms of Sale',
504
- );
505
- const user = userEvent.setup({ delay: null });
506
- await user.click($terms);
507
-
508
- const $button = screen.getByRole('button', {
509
- name: `Pay in one click ${formatPrice(product.price, product.price_currency)}`,
510
- }) as HTMLButtonElement;
511
-
512
- // - wait for address to be loaded.
513
- await screen.findByText(getAddressLabel(billingAddress));
514
-
515
- // - Payment button should not be disabled.
516
- expect($button.disabled).toBe(false);
517
-
518
- // - User clicks on pay button
519
- await user.click($button);
520
-
521
- // - In real world condition the success callback is immediately called for one click payments.
522
- // - but here we need to click manually.
523
- const $success = await screen.findByTestId('payment-success');
524
- await user.click($success);
525
-
526
- // - Route to create order should have been called
527
- // - Furthermore, as payment succeeded immediately, order should have been refetched
528
- const onClickApiCalls = fetchMock.calls().splice(nbApiCalls);
529
- nbApiCalls += 1; // order submit
530
- nbApiCalls += 1; // orders get (invalidate queries)
531
- nbApiCalls += 1; // refetch order (submit invalidate)
532
- nbApiCalls += 1; // fetch validated order
533
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
534
-
535
- expect(onClickApiCalls[0][0]).toBe(
536
- `https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`,
537
- );
538
- expect(JSON.parse(onClickApiCalls[0][1]!.body as string)).toEqual({
539
- billing_address: ObjectHelper.omit(billingAddress, 'id', 'is_main'),
540
- credit_card_id: creditCard.id,
541
- });
542
- expect(onClickApiCalls[2][0]).toBe(
310
+ // - Payment button should have been restore to its idle state
311
+ expect($button.disabled).toBe(false);
312
+ screen.getByRole('button', {
313
+ name: 'Subscribe',
314
+ });
315
+ });
316
+
317
+ it('should start at the save payment method step if order is in state to_save_payment_method', async () => {
318
+ const product = ProductFactory().one();
319
+ const billingAddress = AddressFactory({
320
+ is_main: true,
321
+ }).one();
322
+ const creditCard = CreditCardFactory().one();
323
+ const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
324
+
325
+ fetchMock
326
+ .get(
543
327
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
544
- );
545
- expect(onClickApiCalls[3][0]).toBe(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`);
546
-
547
- // - Spinner should be displayed
548
- screen.getByText('Payment in progress');
549
-
550
- fetchMock.resetHistory();
551
- nbApiCalls = 0;
552
- // - Wait until order has been polled 29 times.
553
- await act(async () => {
554
- await jest.advanceTimersByTimeAsync(
555
- (PAYMENT_SETTINGS.pollLimit - 1) * PAYMENT_SETTINGS.pollInterval,
556
- );
557
- });
558
-
559
- await waitFor(async () => {
560
- expect(fetchMock.calls()).toHaveLength(PAYMENT_SETTINGS.pollLimit - 1);
561
- nbApiCalls += PAYMENT_SETTINGS.pollLimit - 1;
562
- });
563
-
564
- // - This round should be the last
565
- await act(async () => {
566
- jest.runOnlyPendingTimers();
567
- nbApiCalls += 1; // last poll round
328
+ [order],
329
+ )
330
+ .get(
331
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
332
+ [],
333
+ )
334
+ .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
335
+ overwriteRoutes: true,
336
+ })
337
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
338
+ overwriteRoutes: true,
568
339
  });
569
340
 
570
- nbApiCalls += 1; // useProductOrder call (invalidate)
571
- nbApiCalls += 1; // orders get (invalidate queries)
572
-
573
- // - The SaleTunnelNotValidated component should be rendered
574
- screen.getByTestId('generic-sale-tunnel-not-validated-step');
575
- screen.getByText("Sorry, you'll have to wait a little longer!");
341
+ render(<Wrapper product={product} />, {
342
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
343
+ });
576
344
 
577
- // If productType is credential, the button should redirect to the dashboard
578
- if (productType === ProductType.CREDENTIAL) {
579
- const $link = screen.getByRole('link', {
580
- name: 'Close',
581
- });
582
- expect($link.getAttribute('href')).toBe(`/en/dashboard/courses/orders/${order.id}`);
583
- } else {
584
- // Otherwise, the button should close the modal
585
- screen.getByRole('button', {
586
- name: 'Close',
587
- });
588
- }
345
+ nbApiCalls += 3;
346
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
589
347
 
590
- // - And poller should be stopped
591
- await act(async () => {
592
- jest.runOnlyPendingTimers();
593
- });
594
- expect(fetchMock.calls()).toHaveLength(nbApiCalls);
595
- }, 10000);
348
+ await screen.findByTestId('sale-tunnel-save-payment-method-step');
349
+ });
596
350
 
597
- it('should render an error message when payment failed', async () => {
351
+ it.each([OrderState.TO_SIGN, OrderState.SIGNING])(
352
+ 'should start at the sign step if order is in state %s',
353
+ async (state) => {
598
354
  const product = ProductFactory().one();
599
355
  const billingAddress = AddressFactory({
600
356
  is_main: true,
601
357
  }).one();
602
- const { payment_info: paymentInfo, ...order } = OrderWithPaymentFactory().one();
358
+ const creditCard = CreditCardFactory().one();
359
+ const order = OrderFactory({ state }).one();
603
360
 
604
361
  fetchMock
605
362
  .get(
606
363
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
607
- [],
364
+ [order],
608
365
  )
609
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
610
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
611
- payment_info: paymentInfo,
612
- })
613
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, {
614
- ...order,
615
- state: OrderState.SUBMITTED,
616
- })
617
- .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
618
- overwriteRoutes: true,
619
- });
620
-
621
- render(<Wrapper product={product} />, {
622
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
623
- });
624
- nbApiCalls += 1; // useProductOrder get order with filters
625
- nbApiCalls += 1; // get user account call.
626
- nbApiCalls += 1; // get user preferences call.
627
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
628
-
629
- const $terms = screen.getByLabelText(
630
- 'By checking this box, you accept the General Terms of Sale',
631
- );
632
- const user = userEvent.setup({ delay: null });
633
- await user.click($terms);
634
-
635
- const $button = screen.getByRole('button', {
636
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
637
- }) as HTMLButtonElement;
638
-
639
- // - wait for address to be loaded.
640
- await screen.findByText(getAddressLabel(billingAddress));
641
-
642
- // - As all information are provided, payment button should not be disabled.
643
- expect($button.disabled).toBe(false);
644
-
645
- // - User clicks on pay button
646
- await user.click($button);
647
-
648
- // - Route to create order should have been called
649
- nbApiCalls += 1; // order post create (invalidate queries)
650
- nbApiCalls += 1; // order get (invalidate queries)
651
- nbApiCalls += 1; // useProductOrder call (invalidate from create)
652
- nbApiCalls += 1; // order submit (invalidate queries)
653
- nbApiCalls += 1; // order get (invalidate queries)
654
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
655
-
656
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
657
-
658
- // - Spinner should be displayed and payment button should be disabled
659
- screen.getByText('Payment in progress');
660
- expect($button.disabled).toBe(true);
661
-
662
- // - Payment interface should be displayed
663
- await screen.findByText('Payment interface component');
664
-
665
- // - Simulate the payment has failed
666
- await act(async () => {
667
- fireEvent.click(screen.getByTestId('payment-failure'));
668
- });
669
-
670
- // - An error message should be displayed
671
- const $error = screen.getByText('An error occurred during payment. Please retry later.');
672
- expect(document.activeElement).toBe($error);
673
- // - Payment interface should have been closed
674
- expect(screen.queryByText('Payment interface component')).toBeNull();
675
- // - Payment button should have been restore to its idle state
676
- expect($button.disabled).toBe(false);
677
- screen.getByRole('button', {
678
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
679
- });
680
- });
681
-
682
- it('should resubmit the order when user retry to pay after a payment failure', async () => {
683
- const product = ProductFactory().one();
684
- const billingAddress = AddressFactory({
685
- is_main: true,
686
- }).one();
687
- const { payment_info: paymentInfo, ...order } = OrderWithPaymentFactory().one();
688
-
689
- fetchMock
690
366
  .get(
691
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
367
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
692
368
  [],
693
369
  )
694
- .post('https://joanie.endpoint/api/v1.0/orders/', order)
695
- .patch(`https://joanie.endpoint/api/v1.0/orders/${order.id}/submit/`, {
696
- payment_info: paymentInfo,
697
- })
698
- .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, {
699
- ...order,
700
- state: OrderState.SUBMITTED,
370
+ .get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
371
+ overwriteRoutes: true,
701
372
  })
702
373
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
703
374
  overwriteRoutes: true,
@@ -706,135 +377,81 @@ describe.each([
706
377
  render(<Wrapper product={product} />, {
707
378
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
708
379
  });
709
- nbApiCalls += 1; // useProductOrder get order with filters
710
- nbApiCalls += 1; // get user account call.
711
- nbApiCalls += 1; // get user preferences call.
712
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
713
380
 
714
- const $terms = screen.getByLabelText(
715
- 'By checking this box, you accept the General Terms of Sale',
716
- );
717
- const user = userEvent.setup({ delay: null });
718
- await user.click($terms);
719
-
720
- const $button = screen.getByRole('button', {
721
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
722
- }) as HTMLButtonElement;
723
-
724
- // - wait for address to be loaded.
725
- await screen.findByText(getAddressLabel(billingAddress));
726
-
727
- // - User clicks on pay button
728
- await user.click($button);
381
+ nbApiCalls += 3;
382
+ await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
729
383
 
730
- // - Update the response of order list to react to order creation.
731
- fetchMock.get(
384
+ await screen.findByTestId('sale-tunnel-sign-step');
385
+ },
386
+ );
387
+
388
+ it('should show the product payment schedule', async () => {
389
+ const intl = createIntl({ locale: 'en' });
390
+ const product = ProductFactory().one();
391
+ const schedule = PaymentInstallmentFactory().many(2);
392
+ fetchMock
393
+ .get(
732
394
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
733
- [
734
- {
735
- ...order,
736
- state: OrderState.SUBMITTED,
737
- },
738
- ],
739
- { overwriteRoutes: true },
395
+ [],
396
+ )
397
+ .get(
398
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
399
+ schedule,
740
400
  );
741
401
 
742
- // - Route to create order should have been called
743
- nbApiCalls += 1; // order post create (invalidate queries)
744
- nbApiCalls += 1; // order get (invalidate queries)
745
- nbApiCalls += 1; // useProductOrder call (invalidate from create)
746
- nbApiCalls += 1; // order submit (invalidate queries)
747
- nbApiCalls += 1; // order get (invalidate queries)
748
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
749
-
750
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
751
-
752
- // - Payment interface should be displayed
753
- await screen.findByText('Payment interface component');
754
-
755
- // - Simulate the payment has failed
756
- await user.click(screen.getByTestId('payment-failure'));
757
-
758
- // - An error message should be displayed
759
- const $error = screen.getByText('An error occurred during payment. Please retry later.');
760
- expect(document.activeElement).toBe($error);
761
-
762
- // - Payment button should have been restore to its idle state
763
- expect($button.disabled).toBe(false);
764
- screen.getByRole('button', {
765
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
766
- });
767
-
768
- // - User clicks on pay button again
769
- await user.click($button);
402
+ render(<Wrapper product={product} />, {
403
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
404
+ });
770
405
 
771
- nbApiCalls += 1; // order submit (invalidate queries)
772
- nbApiCalls += 1; // order get (invalidate queries)
773
- nbApiCalls += 1; // useProductOrder call (invalidate from submit)
774
- await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
406
+ await screen.findByRole('heading', {
407
+ level: 4,
408
+ name: 'Payment schedule',
775
409
  });
776
410
 
777
- it('should show an error if user does not accept the terms', async () => {
778
- const product = ProductFactory().one();
779
- const billingAddress = AddressFactory({ is_main: true }).one();
411
+ const scheduleTable = screen.getByRole('table');
412
+ const scheduleTableRows = within(scheduleTable).getAllByRole('row');
413
+ expect(scheduleTableRows).toHaveLength(schedule.length);
780
414
 
781
- fetchMock
782
- .get(
783
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
784
- [],
785
- )
786
- .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
787
- overwriteRoutes: true,
788
- });
789
-
790
- render(<Wrapper product={product} />, {
791
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
415
+ scheduleTableRows.forEach((row, index) => {
416
+ const installment = schedule[index];
417
+ // A first column should show the installment index
418
+ within(row).getByRole('cell', {
419
+ name: (index + 1).toString(),
792
420
  });
793
-
794
- const $button = screen.getByRole('button', {
795
- name: `Pay ${formatPrice(product.price, product.price_currency)}`,
796
- }) as HTMLButtonElement;
797
-
798
- // - As all information are provided, payment button should not be disabled.
799
- expect($button.disabled).toBe(false);
800
-
801
- expect(screen.queryByText('You must accept the terms')).not.toBeInTheDocument();
802
-
803
- // - User clicks on pay button
804
- await act(async () => {
805
- fireEvent.click($button);
421
+ // A 2nd column should show the installment amount
422
+ within(row).getByRole('cell', {
423
+ name: formatPrice(installment.amount, installment.currency),
424
+ });
425
+ // A 3rd column should show the installment withdraw date
426
+ within(row).getByRole('cell', {
427
+ name: `Withdrawn on ${intl.formatDate(installment.due_date, {
428
+ ...DEFAULT_DATE_FORMAT,
429
+ })}`,
430
+ });
431
+ // A 4th column should show the installment state
432
+ within(row).getByRole('cell', {
433
+ name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
806
434
  });
807
-
808
- expect(screen.getByText('You must accept the terms.')).toBeInTheDocument();
809
435
  });
436
+ });
810
437
 
811
- it('should show a link to the platform terms and conditions', async () => {
812
- const product = ProductFactory().one();
813
-
814
- const fetchOrderQueryParams =
815
- product.type === ProductType.CREDENTIAL
816
- ? {
817
- course_code: course.code,
818
- product_id: product.id,
819
- state: ['pending', 'validated', 'submitted'],
820
- }
821
- : {
822
- enrollment_id: enrollment?.id,
823
- product_id: product.id,
824
- state: ['pending', 'validated', 'submitted'],
825
- };
826
-
827
- fetchMock.get(
828
- `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(fetchOrderQueryParams)}`,
438
+ it('should show a walkthrough to explain the subscription process', async () => {
439
+ const product = ProductFactory().one();
440
+ const schedule = PaymentInstallmentFactory().many(2);
441
+ fetchMock
442
+ .get(
443
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
829
444
  [],
445
+ )
446
+ .get(
447
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
448
+ schedule,
830
449
  );
831
450
 
832
- render(<Wrapper product={product} />, {
833
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
834
- });
835
-
836
- const $terms = screen.getByRole('link', { name: 'General Terms of Sale' });
837
- expect($terms).toHaveAttribute('href', '/en/about/terms-and-conditions/');
451
+ render(<Wrapper product={product} />, {
452
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
838
453
  });
839
- },
840
- );
454
+
455
+ screen.getByTestId('walkthrough-banner');
456
+ });
457
+ });