@faststore/core 4.6.1-dev.0 → 4.7.0-dev.0
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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +104 -92
- package/@generated/cached-operations.json +1 -0
- package/@generated/gql.ts +6 -0
- package/@generated/graphql.ts +61 -0
- package/@generated/persisted-documents.json +1 -0
- package/@generated/schema.graphql +56 -0
- package/CHANGELOG.md +6 -0
- package/cms/faststore/my-account/components/cms_component__accountlistcards.jsonc +90 -0
- package/cms/faststore/my-account/pages/cms_content_type__myaccountcards.jsonc +18 -0
- package/package.json +6 -6
- package/public/icons.svg +1 -1
- package/src/components/account/Layout/Layout.tsx +4 -0
- package/src/components/account/cards/MyAccountListCards/MyAccountListCards.tsx +409 -0
- package/src/components/account/cards/MyAccountListCards/index.ts +11 -0
- package/src/components/account/cards/MyAccountListCards/myAccountListCardsLabels.ts +44 -0
- package/src/components/account/cards/MyAccountListCards/styles.module.scss +126 -0
- package/src/components/cms/account/Components.ts +2 -0
- package/src/components/sections/Account/AccountListCards.tsx +69 -0
- package/src/pages/pvt/account/cards/index.tsx +244 -0
- package/src/sdk/account/accountPageContext.ts +16 -0
- package/src/sdk/account/getB2BSessionClaims.ts +42 -0
- package/src/sdk/account/getMyAccountRoutes.ts +7 -0
- package/src/server/cms/myAccountDefaultSections.ts +2 -0
- package/src/utils/getCookie.ts +8 -1
- package/test/components/account/cards/MyAccountListCards.browser.test.tsx +301 -0
- package/test/components/sections/Account/AccountListCards.browser.test.tsx +57 -0
- package/test/pages/pvt/account/cards/index.test.ts +202 -0
- package/test/sdk/account/getB2BSessionClaims.test.ts +48 -0
- package/test/sdk/account/getMyAccountRoutes.test.ts +10 -0
- package/test/server/index.test.ts +2 -0
- package/test/utils/getCookie.test.ts +23 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const mockReload = vi.hoisted(() => vi.fn())
|
|
6
|
+
const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ reload: mockReload })))
|
|
7
|
+
|
|
8
|
+
vi.mock('next/router', () => ({
|
|
9
|
+
useRouter: mockUseRouter,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { MyAccountListCards } from 'src/components/account/cards/MyAccountListCards'
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanup()
|
|
16
|
+
mockReload.mockClear()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const VISA_PERSONAL = {
|
|
20
|
+
accountId: 'acc-1',
|
|
21
|
+
bin: '411111',
|
|
22
|
+
cardNumber: '**** **** **** 1111',
|
|
23
|
+
paymentSystem: '2',
|
|
24
|
+
paymentSystemName: 'Visa',
|
|
25
|
+
isDefault: true,
|
|
26
|
+
isActive: true,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MASTERCARD_PERSONAL = {
|
|
30
|
+
accountId: 'acc-2',
|
|
31
|
+
bin: '555555',
|
|
32
|
+
cardNumber: '**** **** **** 2222',
|
|
33
|
+
paymentSystem: '3',
|
|
34
|
+
paymentSystemName: 'Mastercard',
|
|
35
|
+
isDefault: false,
|
|
36
|
+
isActive: true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const AMEX_SHARED = {
|
|
40
|
+
accountId: 'acc-3',
|
|
41
|
+
bin: '378282',
|
|
42
|
+
cardNumber: '**** **** **** 3333',
|
|
43
|
+
paymentSystem: '4',
|
|
44
|
+
paymentSystemName: 'Amex',
|
|
45
|
+
isDefault: false,
|
|
46
|
+
isActive: true,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('MyAccountListCards', () => {
|
|
50
|
+
it('shows the tab bar and renders Personal cards by default', () => {
|
|
51
|
+
render(
|
|
52
|
+
<MyAccountListCards
|
|
53
|
+
personalCards={[VISA_PERSONAL, MASTERCARD_PERSONAL]}
|
|
54
|
+
sharedCards={[AMEX_SHARED]}
|
|
55
|
+
hasOrgAssociation
|
|
56
|
+
canViewPersonalCards
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
|
61
|
+
expect(screen.getByRole('tab', { name: 'Personal' })).toHaveAttribute(
|
|
62
|
+
'aria-selected',
|
|
63
|
+
'true'
|
|
64
|
+
)
|
|
65
|
+
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
66
|
+
expect(screen.getByText('Mastercard')).toBeInTheDocument()
|
|
67
|
+
expect(screen.queryByText('Amex')).not.toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('marks the default card with the default badge', () => {
|
|
71
|
+
render(
|
|
72
|
+
<MyAccountListCards
|
|
73
|
+
personalCards={[VISA_PERSONAL, MASTERCARD_PERSONAL]}
|
|
74
|
+
sharedCards={[]}
|
|
75
|
+
hasOrgAssociation={false}
|
|
76
|
+
canViewPersonalCards
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const visaItem = screen
|
|
81
|
+
.getByText('Visa')
|
|
82
|
+
.closest('[data-fs-list-cards-item]')
|
|
83
|
+
expect(visaItem).toHaveTextContent('Default')
|
|
84
|
+
|
|
85
|
+
const mastercardItem = screen
|
|
86
|
+
.getByText('Mastercard')
|
|
87
|
+
.closest('[data-fs-list-cards-item]')
|
|
88
|
+
expect(mastercardItem).not.toHaveTextContent('Default')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('falls back to the generic card label when paymentSystemName is absent', () => {
|
|
92
|
+
render(
|
|
93
|
+
<MyAccountListCards
|
|
94
|
+
personalCards={[{ accountId: 'acc-9', cardNumber: '**** 9999' }]}
|
|
95
|
+
sharedCards={[]}
|
|
96
|
+
hasOrgAssociation={false}
|
|
97
|
+
canViewPersonalCards
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(screen.getByText('Card')).toBeInTheDocument()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('prefers cardLabel over paymentSystemName when the service has a nickname on file', () => {
|
|
105
|
+
render(
|
|
106
|
+
<MyAccountListCards
|
|
107
|
+
personalCards={[{ ...VISA_PERSONAL, cardLabel: 'Team lunch card' }]}
|
|
108
|
+
sharedCards={[]}
|
|
109
|
+
hasOrgAssociation={false}
|
|
110
|
+
canViewPersonalCards
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(screen.getByText('Team lunch card')).toBeInTheDocument()
|
|
115
|
+
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('matches cardLabel when searching', () => {
|
|
119
|
+
render(
|
|
120
|
+
<MyAccountListCards
|
|
121
|
+
personalCards={[
|
|
122
|
+
{ ...VISA_PERSONAL, cardLabel: 'Team lunch card' },
|
|
123
|
+
MASTERCARD_PERSONAL,
|
|
124
|
+
]}
|
|
125
|
+
sharedCards={[]}
|
|
126
|
+
hasOrgAssociation={false}
|
|
127
|
+
canViewPersonalCards
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
fireEvent.change(screen.getByPlaceholderText('Search'), {
|
|
132
|
+
target: { value: 'lunch' },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('Team lunch card')).toBeInTheDocument()
|
|
136
|
+
expect(screen.queryByText('Mastercard')).not.toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('switches to the Shared tab by clicking it, and shows the tooltip info icon', () => {
|
|
140
|
+
render(
|
|
141
|
+
<MyAccountListCards
|
|
142
|
+
personalCards={[VISA_PERSONAL]}
|
|
143
|
+
sharedCards={[AMEX_SHARED]}
|
|
144
|
+
hasOrgAssociation
|
|
145
|
+
canViewPersonalCards
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Shared' }))
|
|
150
|
+
|
|
151
|
+
expect(screen.getByRole('tab', { name: 'Shared' })).toHaveAttribute(
|
|
152
|
+
'aria-selected',
|
|
153
|
+
'true'
|
|
154
|
+
)
|
|
155
|
+
expect(screen.getByText('Amex')).toBeInTheDocument()
|
|
156
|
+
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('moves focus and switches tabs on ArrowRight/ArrowLeft', () => {
|
|
160
|
+
render(
|
|
161
|
+
<MyAccountListCards
|
|
162
|
+
personalCards={[VISA_PERSONAL]}
|
|
163
|
+
sharedCards={[AMEX_SHARED]}
|
|
164
|
+
hasOrgAssociation
|
|
165
|
+
canViewPersonalCards
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const personalTab = screen.getByRole('tab', { name: 'Personal' })
|
|
170
|
+
const sharedTab = screen.getByRole('tab', { name: 'Shared' })
|
|
171
|
+
|
|
172
|
+
personalTab.focus()
|
|
173
|
+
fireEvent.keyDown(personalTab, { key: 'ArrowRight' })
|
|
174
|
+
expect(sharedTab).toHaveAttribute('aria-selected', 'true')
|
|
175
|
+
expect(sharedTab).toHaveFocus()
|
|
176
|
+
|
|
177
|
+
fireEvent.keyDown(sharedTab, { key: 'ArrowLeft' })
|
|
178
|
+
expect(personalTab).toHaveAttribute('aria-selected', 'true')
|
|
179
|
+
expect(personalTab).toHaveFocus()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('renders shared cards directly with no tab chrome when Personal is unavailable (sharedOnly)', () => {
|
|
183
|
+
render(
|
|
184
|
+
<MyAccountListCards
|
|
185
|
+
personalCards={[]}
|
|
186
|
+
sharedCards={[AMEX_SHARED]}
|
|
187
|
+
hasOrgAssociation
|
|
188
|
+
canViewPersonalCards={false}
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
|
193
|
+
expect(screen.getByText('Amex')).toBeInTheDocument()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('renders only the Personal tab when there is no org association (unconfirmed combo kept as-is)', () => {
|
|
197
|
+
render(
|
|
198
|
+
<MyAccountListCards
|
|
199
|
+
personalCards={[VISA_PERSONAL]}
|
|
200
|
+
sharedCards={[]}
|
|
201
|
+
hasOrgAssociation={false}
|
|
202
|
+
canViewPersonalCards={false}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
|
207
|
+
expect(
|
|
208
|
+
screen.queryByRole('tab', { name: 'Shared' })
|
|
209
|
+
).not.toBeInTheDocument()
|
|
210
|
+
expect(screen.getByRole('tab', { name: 'Personal' })).toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('filters cards by search term', () => {
|
|
214
|
+
render(
|
|
215
|
+
<MyAccountListCards
|
|
216
|
+
personalCards={[VISA_PERSONAL, MASTERCARD_PERSONAL]}
|
|
217
|
+
sharedCards={[]}
|
|
218
|
+
hasOrgAssociation={false}
|
|
219
|
+
canViewPersonalCards
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
fireEvent.change(screen.getByPlaceholderText('Search'), {
|
|
224
|
+
target: { value: 'visa' },
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
228
|
+
expect(screen.queryByText('Mastercard')).not.toBeInTheDocument()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('shows the no-results empty state when the search matches nothing', () => {
|
|
232
|
+
render(
|
|
233
|
+
<MyAccountListCards
|
|
234
|
+
personalCards={[VISA_PERSONAL]}
|
|
235
|
+
sharedCards={[]}
|
|
236
|
+
hasOrgAssociation={false}
|
|
237
|
+
canViewPersonalCards
|
|
238
|
+
/>
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
fireEvent.change(screen.getByPlaceholderText('Search'), {
|
|
242
|
+
target: { value: 'nonexistent-brand' },
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(screen.getByText('No results found')).toBeInTheDocument()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('shows the empty state when there are no cards at all', () => {
|
|
249
|
+
render(
|
|
250
|
+
<MyAccountListCards
|
|
251
|
+
personalCards={[]}
|
|
252
|
+
sharedCards={[]}
|
|
253
|
+
hasOrgAssociation={false}
|
|
254
|
+
canViewPersonalCards
|
|
255
|
+
/>
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
expect(screen.getByText("You don't have any cards")).toBeInTheDocument()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('paginates when there are more than 25 cards', () => {
|
|
262
|
+
const manyCards = Array.from({ length: 26 }, (_, i) => ({
|
|
263
|
+
accountId: `acc-${i}`,
|
|
264
|
+
paymentSystemName: `Brand ${i}`,
|
|
265
|
+
cardNumber: `**** ${i}`,
|
|
266
|
+
}))
|
|
267
|
+
|
|
268
|
+
render(
|
|
269
|
+
<MyAccountListCards
|
|
270
|
+
personalCards={manyCards}
|
|
271
|
+
sharedCards={[]}
|
|
272
|
+
hasOrgAssociation={false}
|
|
273
|
+
canViewPersonalCards
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(screen.getByText('Brand 0')).toBeInTheDocument()
|
|
278
|
+
expect(screen.queryByText('Brand 25')).not.toBeInTheDocument()
|
|
279
|
+
|
|
280
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next Page' }))
|
|
281
|
+
|
|
282
|
+
expect(screen.getByText('Brand 25')).toBeInTheDocument()
|
|
283
|
+
expect(screen.queryByText('Brand 0')).not.toBeInTheDocument()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('shows the error state with a retry action that reloads the page', () => {
|
|
287
|
+
render(
|
|
288
|
+
<MyAccountListCards
|
|
289
|
+
personalCards={[]}
|
|
290
|
+
sharedCards={[]}
|
|
291
|
+
hasOrgAssociation={false}
|
|
292
|
+
canViewPersonalCards
|
|
293
|
+
hasError
|
|
294
|
+
/>
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
expect(screen.getByText("We couldn't load your cards")).toBeInTheDocument()
|
|
298
|
+
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
|
299
|
+
expect(mockReload).toHaveBeenCalledTimes(1)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const mockUseAccountPageData = vi.hoisted(() => vi.fn())
|
|
6
|
+
const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ reload: vi.fn() })))
|
|
7
|
+
|
|
8
|
+
vi.mock('next/router', () => ({ useRouter: mockUseRouter }))
|
|
9
|
+
vi.mock('src/sdk/account/accountPageContext', () => ({
|
|
10
|
+
useAccountPageData: mockUseAccountPageData,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import AccountListCards from 'src/components/sections/Account/AccountListCards'
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup)
|
|
16
|
+
|
|
17
|
+
describe('AccountListCards', () => {
|
|
18
|
+
it('reads accountPageData and forwards it to MyAccountListCards', () => {
|
|
19
|
+
mockUseAccountPageData.mockReturnValueOnce({
|
|
20
|
+
personalCards: [{ accountId: 'acc-1', paymentSystemName: 'Visa' }],
|
|
21
|
+
sharedCards: [],
|
|
22
|
+
hasOrgAssociation: false,
|
|
23
|
+
canViewPersonalCards: true,
|
|
24
|
+
hasError: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
render(<AccountListCards />)
|
|
28
|
+
|
|
29
|
+
expect(screen.getByText('Visa')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('falls back to empty card arrays when the page data omits them', () => {
|
|
33
|
+
mockUseAccountPageData.mockReturnValueOnce({
|
|
34
|
+
hasOrgAssociation: false,
|
|
35
|
+
canViewPersonalCards: true,
|
|
36
|
+
hasError: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
render(<AccountListCards />)
|
|
40
|
+
|
|
41
|
+
expect(screen.getByText("You don't have any cards")).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('applies CMS-provided label overrides', () => {
|
|
45
|
+
mockUseAccountPageData.mockReturnValueOnce({
|
|
46
|
+
personalCards: [],
|
|
47
|
+
sharedCards: [],
|
|
48
|
+
hasOrgAssociation: false,
|
|
49
|
+
canViewPersonalCards: true,
|
|
50
|
+
hasError: false,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
render(<AccountListCards pageTitle="Meus Cartões" />)
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText('Meus Cartões')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const mockExecute = vi.hoisted(() => vi.fn())
|
|
4
|
+
const mockGetB2BSessionClaims = vi.hoisted(() => vi.fn())
|
|
5
|
+
const mockGetIsRepresentative = vi.hoisted(() => vi.fn(() => true))
|
|
6
|
+
const mockGetMyAccountRedirect = vi.hoisted(() =>
|
|
7
|
+
vi.fn(() => ({ isFaststoreMyAccountEnabled: true, redirect: undefined }))
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
vi.mock('src/server', () => ({ execute: mockExecute }))
|
|
11
|
+
vi.mock('src/server/cms/global', () => ({
|
|
12
|
+
injectGlobalSections: vi.fn(() => ({ sections: [], settings: {} })),
|
|
13
|
+
}))
|
|
14
|
+
vi.mock('src/server/cms/myAccountDefaultSections', () => ({
|
|
15
|
+
extractAccountNavigationData: vi.fn(() => ({
|
|
16
|
+
pageSections: [],
|
|
17
|
+
navigationData: {},
|
|
18
|
+
})),
|
|
19
|
+
}))
|
|
20
|
+
vi.mock('src/server/cms/fetchMyAccountPageContent', () => ({
|
|
21
|
+
fetchMyAccountPageContent: vi.fn(async () => ({ sections: [] })),
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('src/components/cms/GlobalSections', () => ({
|
|
24
|
+
getGlobalSectionsData: vi.fn(() => [
|
|
25
|
+
Promise.resolve(null),
|
|
26
|
+
Promise.resolve(null),
|
|
27
|
+
Promise.resolve(null),
|
|
28
|
+
]),
|
|
29
|
+
}))
|
|
30
|
+
vi.mock('src/sdk/account/getB2BSessionClaims', () => ({
|
|
31
|
+
getB2BSessionClaims: mockGetB2BSessionClaims,
|
|
32
|
+
}))
|
|
33
|
+
vi.mock('src/sdk/account/getIsRepresentative', () => ({
|
|
34
|
+
getIsRepresentative: mockGetIsRepresentative,
|
|
35
|
+
}))
|
|
36
|
+
vi.mock('src/utils/myAccountRedirect', () => ({
|
|
37
|
+
getMyAccountRedirect: mockGetMyAccountRedirect,
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
import { getServerSideProps } from 'src/pages/pvt/account/cards/index'
|
|
41
|
+
|
|
42
|
+
function makeContext() {
|
|
43
|
+
return {
|
|
44
|
+
req: { headers: {} },
|
|
45
|
+
query: {},
|
|
46
|
+
previewData: undefined,
|
|
47
|
+
locale: undefined,
|
|
48
|
+
} as any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CARD_PERSONAL = { accountId: 'p1', origin: 'personal' }
|
|
52
|
+
const CARD_SHARED = { accountId: 's1', origin: 'shared' }
|
|
53
|
+
|
|
54
|
+
function mockSuccessfulExecute({
|
|
55
|
+
cards = [CARD_PERSONAL, CARD_SHARED],
|
|
56
|
+
hasAdHocCardAccess = true,
|
|
57
|
+
accountName = 'Jane Buyer',
|
|
58
|
+
} = {}) {
|
|
59
|
+
mockExecute.mockResolvedValueOnce({
|
|
60
|
+
data: {
|
|
61
|
+
listCreditCards: { list: cards },
|
|
62
|
+
accountProfile: { name: accountName },
|
|
63
|
+
hasAdHocCardAccess,
|
|
64
|
+
},
|
|
65
|
+
errors: undefined,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('Cards page getServerSideProps', () => {
|
|
70
|
+
it('redirects when the FastStore My Account experience is disabled', async () => {
|
|
71
|
+
mockGetMyAccountRedirect.mockReturnValueOnce({
|
|
72
|
+
isFaststoreMyAccountEnabled: false,
|
|
73
|
+
redirect: { destination: '/account', permanent: false },
|
|
74
|
+
})
|
|
75
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
76
|
+
hasOrgAssociation: false,
|
|
77
|
+
hasCustomerId: false,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const result = await getServerSideProps(makeContext())
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
redirect: { destination: '/account', permanent: false },
|
|
84
|
+
})
|
|
85
|
+
expect(mockExecute).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('redirects to /pvt/account/403 when the query fails with 401/403', async () => {
|
|
89
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
90
|
+
hasOrgAssociation: false,
|
|
91
|
+
hasCustomerId: false,
|
|
92
|
+
})
|
|
93
|
+
mockExecute.mockResolvedValueOnce({
|
|
94
|
+
data: undefined,
|
|
95
|
+
errors: [{ extensions: { status: 401 } }],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result: any = await getServerSideProps(makeContext())
|
|
99
|
+
|
|
100
|
+
expect(result.redirect.destination).toContain('/pvt/account/403')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders the error state (no redirect) when the query fails with a non-auth status', async () => {
|
|
104
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
105
|
+
hasOrgAssociation: false,
|
|
106
|
+
hasCustomerId: false,
|
|
107
|
+
})
|
|
108
|
+
mockExecute.mockResolvedValueOnce({
|
|
109
|
+
data: undefined,
|
|
110
|
+
errors: [{ extensions: { status: 502 } }],
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const result: any = await getServerSideProps(makeContext())
|
|
114
|
+
|
|
115
|
+
expect(result.redirect).toBeUndefined()
|
|
116
|
+
expect(result.props.accountPageData.hasError).toBe(true)
|
|
117
|
+
expect(result.props.accountPageData.personalCards).toEqual([])
|
|
118
|
+
expect(result.props.accountPageData.sharedCards).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('partitions the merged card list by origin and computes canViewPersonalCards', async () => {
|
|
122
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
123
|
+
hasOrgAssociation: true,
|
|
124
|
+
hasCustomerId: true,
|
|
125
|
+
})
|
|
126
|
+
mockSuccessfulExecute()
|
|
127
|
+
|
|
128
|
+
const result: any = await getServerSideProps(makeContext())
|
|
129
|
+
|
|
130
|
+
expect(result.props.accountPageData.personalCards).toEqual([CARD_PERSONAL])
|
|
131
|
+
expect(result.props.accountPageData.sharedCards).toEqual([CARD_SHARED])
|
|
132
|
+
expect(result.props.accountPageData.canViewPersonalCards).toBe(true)
|
|
133
|
+
expect(result.props.accountPageData.hasOrgAssociation).toBe(true)
|
|
134
|
+
expect(result.props.accountName).toBe('Jane Buyer')
|
|
135
|
+
expect(result.props).not.toHaveProperty('hasAdHocCardAccess')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('never gates the route: canViewPersonalCards is false but no redirect happens', async () => {
|
|
139
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
140
|
+
hasOrgAssociation: true,
|
|
141
|
+
hasCustomerId: false,
|
|
142
|
+
})
|
|
143
|
+
mockSuccessfulExecute({ hasAdHocCardAccess: false })
|
|
144
|
+
|
|
145
|
+
const result: any = await getServerSideProps(makeContext())
|
|
146
|
+
|
|
147
|
+
expect(result.redirect).toBeUndefined()
|
|
148
|
+
expect(result.props.accountPageData.canViewPersonalCards).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('drops personal cards from the SSR payload in the sharedOnly combination (hasOrgAssociation, canViewPersonalCards false)', async () => {
|
|
152
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
153
|
+
hasOrgAssociation: true,
|
|
154
|
+
hasCustomerId: false,
|
|
155
|
+
})
|
|
156
|
+
mockSuccessfulExecute({ hasAdHocCardAccess: false })
|
|
157
|
+
|
|
158
|
+
const result: any = await getServerSideProps(makeContext())
|
|
159
|
+
|
|
160
|
+
// The UI hides the Personal tab only for this combination (sharedOnly);
|
|
161
|
+
// this asserts the data itself never reaches __NEXT_DATA__ either (PR
|
|
162
|
+
// review, REQ-6's "hide" intent) — not just that the tab is visually
|
|
163
|
+
// hidden.
|
|
164
|
+
expect(result.props.accountPageData.personalCards).toEqual([])
|
|
165
|
+
expect(result.props.accountPageData.sharedCards).toEqual([CARD_SHARED])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('keeps personal cards in the payload when there is no org association, even though canViewPersonalCards is false (FR-7: B2C buyers are never subject to this check)', async () => {
|
|
169
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
170
|
+
hasOrgAssociation: false,
|
|
171
|
+
hasCustomerId: false,
|
|
172
|
+
})
|
|
173
|
+
mockSuccessfulExecute({ hasAdHocCardAccess: false })
|
|
174
|
+
|
|
175
|
+
const result: any = await getServerSideProps(makeContext())
|
|
176
|
+
|
|
177
|
+
// Without hasOrgAssociation, the component still renders the Personal
|
|
178
|
+
// tab (the "kept as-is" O1 combination) — stripping the data here would
|
|
179
|
+
// silently regress a buyer this gate was never meant to touch, even
|
|
180
|
+
// though canViewPersonalCards itself is false.
|
|
181
|
+
expect(result.props.accountPageData.personalCards).toEqual([CARD_PERSONAL])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('defaults hasAdHocCardAccess to allowed when the query field is missing', async () => {
|
|
185
|
+
mockGetB2BSessionClaims.mockReturnValueOnce({
|
|
186
|
+
hasOrgAssociation: true,
|
|
187
|
+
hasCustomerId: true,
|
|
188
|
+
})
|
|
189
|
+
mockExecute.mockResolvedValueOnce({
|
|
190
|
+
data: {
|
|
191
|
+
listCreditCards: { list: [] },
|
|
192
|
+
accountProfile: { name: 'Jane' },
|
|
193
|
+
hasAdHocCardAccess: undefined,
|
|
194
|
+
},
|
|
195
|
+
errors: undefined,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const result: any = await getServerSideProps(makeContext())
|
|
199
|
+
|
|
200
|
+
expect(result.props.accountPageData.canViewPersonalCards).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getB2BSessionClaims } from '../../../src/sdk/account/getB2BSessionClaims'
|
|
3
|
+
|
|
4
|
+
const ACCOUNT = 'storeaccount'
|
|
5
|
+
|
|
6
|
+
const makeJwt = (payload: Record<string, unknown>) =>
|
|
7
|
+
`header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`
|
|
8
|
+
|
|
9
|
+
describe('getB2BSessionClaims', () => {
|
|
10
|
+
it('reads hasOrgAssociation and hasCustomerId from the token claims', () => {
|
|
11
|
+
const cookie = `VtexIdclientAutCookie_${ACCOUNT}=${makeJwt({ unitId: 'unit-1', customerId: 'customer-1' })}`
|
|
12
|
+
|
|
13
|
+
const result = getB2BSessionClaims({
|
|
14
|
+
headers: { cookie },
|
|
15
|
+
account: ACCOUNT,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(result).toEqual({ hasOrgAssociation: true, hasCustomerId: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns false claims when the token has neither unitId nor customerId', () => {
|
|
22
|
+
const cookie = `VtexIdclientAutCookie_${ACCOUNT}=${makeJwt({})}`
|
|
23
|
+
|
|
24
|
+
const result = getB2BSessionClaims({
|
|
25
|
+
headers: { cookie },
|
|
26
|
+
account: ACCOUNT,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(result).toEqual({ hasOrgAssociation: false, hasCustomerId: false })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns false claims (not a throw) when there is no auth cookie at all', () => {
|
|
33
|
+
const result = getB2BSessionClaims({ headers: {}, account: ACCOUNT })
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual({ hasOrgAssociation: false, hasCustomerId: false })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not throw when the auth cookie is malformed', () => {
|
|
39
|
+
const cookie = `VtexIdclientAutCookie_${ACCOUNT}=not-a-jwt`
|
|
40
|
+
|
|
41
|
+
const result = getB2BSessionClaims({
|
|
42
|
+
headers: { cookie },
|
|
43
|
+
account: ACCOUNT,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({ hasOrgAssociation: false, hasCustomerId: false })
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
CARDS_ROUTE,
|
|
4
5
|
ORDERS_ROUTE,
|
|
5
6
|
PROFILE_ROUTE,
|
|
6
7
|
QUOTES_ROUTE,
|
|
@@ -20,6 +21,7 @@ describe('getMyAccountRoutes', () => {
|
|
|
20
21
|
expect(result.map((r) => r.route)).toEqual([
|
|
21
22
|
PROFILE_ROUTE,
|
|
22
23
|
ORDERS_ROUTE,
|
|
24
|
+
CARDS_ROUTE,
|
|
23
25
|
QUOTES_ROUTE,
|
|
24
26
|
USER_DETAILS_ROUTE,
|
|
25
27
|
SECURITY_ROUTE,
|
|
@@ -27,6 +29,14 @@ describe('getMyAccountRoutes', () => {
|
|
|
27
29
|
])
|
|
28
30
|
})
|
|
29
31
|
|
|
32
|
+
it('keeps routes with /pvt prefix unchanged', () => {
|
|
33
|
+
const result = getMyAccountRoutes({
|
|
34
|
+
routes: [{ title: 'Custom', route: '/pvt/custom' }],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(result.find((r) => r.title === 'Custom')?.route).toBe('/pvt/custom')
|
|
38
|
+
})
|
|
39
|
+
|
|
30
40
|
it('prefixes routes with /pvt when missing', () => {
|
|
31
41
|
const result = getMyAccountRoutes({
|
|
32
42
|
routes: [{ title: 'Custom', route: '/custom' }],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseJwt } from '../../src/utils/getCookie'
|
|
3
|
+
|
|
4
|
+
describe('parseJwt', () => {
|
|
5
|
+
it('returns null for an empty token', () => {
|
|
6
|
+
expect(parseJwt('')).toBeNull()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('decodes a well-formed token payload', () => {
|
|
10
|
+
const payload = { unitId: 'unit-1' }
|
|
11
|
+
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`
|
|
12
|
+
|
|
13
|
+
expect(parseJwt(token)).toEqual(payload)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns null instead of throwing for a malformed token', () => {
|
|
17
|
+
expect(parseJwt('not-a-jwt')).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null instead of throwing when the payload segment is not valid base64/JSON', () => {
|
|
21
|
+
expect(parseJwt('header.%%%not-base64%%%.signature')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
})
|