@faststore/core 4.6.0-dev.0 → 4.6.0-dev.2
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 +3 -3
- package/.turbo/turbo-test.log +102 -91
- package/@generated/gql.ts +3 -3
- package/@generated/graphql.ts +8 -2
- package/@generated/persisted-documents.json +1 -1
- package/@generated/schema.graphql +6 -0
- package/CHANGELOG.md +45 -0
- package/package.json +6 -6
- package/src/components/product/ProductGrid/ProductGrid.tsx +1 -0
- package/src/components/skeletons/ProductGridSkeleton/ProductGridSkeleton.tsx +4 -1
- package/src/components/templates/SearchPage/SearchWrapper.tsx +15 -5
- package/src/components/ui/ProductGallery/GalleryPageHeightLock.tsx +238 -0
- package/src/components/ui/ProductGallery/ProductGallery.tsx +69 -24
- package/src/sdk/cart/index.ts +15 -1
- package/src/sdk/cart/waitForSessionValidated.ts +43 -0
- package/src/sdk/session/index.ts +14 -0
- package/src/sdk/session/syncSalesChannelFromOrderForm.ts +68 -0
- package/src/sdk/ui/useScrollRestoration.tsx +616 -32
- package/src/styles/global/index.scss +12 -0
- package/test/components/skeletons/ProductGridSkeleton.browser.test.tsx +32 -0
- package/test/components/templates/SearchPage/SearchWrapper.browser.test.tsx +137 -0
- package/test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx +230 -0
- package/test/sdk/cart/waitForSessionValidated.test.ts +96 -0
- package/test/sdk/session/syncSalesChannelFromOrderForm.test.ts +125 -0
- package/test/sdk/ui/useScrollRestoration.browser.test.ts +798 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
vi.mock('src/components/skeletons/ProductCardSkeleton', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: () => <div data-testid="product-card-skeleton" />,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import ProductGridSkeleton from '../../../src/components/skeletons/ProductGridSkeleton/ProductGridSkeleton'
|
|
14
|
+
|
|
15
|
+
describe('ProductGridSkeleton', () => {
|
|
16
|
+
it('renders the requested number of placeholder cards', () => {
|
|
17
|
+
render(<ProductGridSkeleton loading count={4} />)
|
|
18
|
+
|
|
19
|
+
expect(screen.getAllByTestId('product-card-skeleton')).toHaveLength(4)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders children when not loading', () => {
|
|
23
|
+
render(
|
|
24
|
+
<ProductGridSkeleton loading={false}>
|
|
25
|
+
<div data-testid="grid-content">products</div>
|
|
26
|
+
</ProductGridSkeleton>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
expect(screen.getByTestId('grid-content')).toBeTruthy()
|
|
30
|
+
expect(screen.queryByTestId('product-card-skeleton')).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import { act, render, waitFor } from '@testing-library/react'
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const replace = vi.fn()
|
|
10
|
+
const resetInfiniteScroll = vi.fn()
|
|
11
|
+
const useProductGalleryQuery = vi.fn()
|
|
12
|
+
|
|
13
|
+
vi.mock('next/router', () => ({
|
|
14
|
+
useRouter: () => ({ replace }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('@faststore/sdk', () => ({
|
|
18
|
+
useSearch: () => ({
|
|
19
|
+
state: { term: 'refil', sort: 'score_desc', selectedFacets: [] },
|
|
20
|
+
pages: [0],
|
|
21
|
+
resetInfiniteScroll,
|
|
22
|
+
}),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('discovery.config', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
default: {
|
|
28
|
+
experimental: { enableSearchSSR: false },
|
|
29
|
+
seo: { search: { bodyH1: 'Showing results for:' } },
|
|
30
|
+
},
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
vi.mock('src/sdk/product/useProductGalleryQuery', () => ({
|
|
34
|
+
useProductGalleryQuery: (...args: unknown[]) =>
|
|
35
|
+
useProductGalleryQuery(...args),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
vi.mock('src/components/cms/RenderSections', () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('src/sdk/overrides/PageProvider', () => ({
|
|
44
|
+
__esModule: true,
|
|
45
|
+
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
vi.mock('src/components/templates/SearchPage/EmptySearch', () => ({
|
|
49
|
+
__esModule: true,
|
|
50
|
+
default: () => <div data-testid="empty-search" />,
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
vi.mock('src/components/templates/SearchPage/SearchPage', () => ({
|
|
54
|
+
__esModule: true,
|
|
55
|
+
default: () => <div data-testid="search-page" />,
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
import SearchWrapper from 'src/components/templates/SearchPage/SearchWrapper'
|
|
59
|
+
|
|
60
|
+
describe('SearchWrapper Intelligent Search redirect', () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
replace.mockClear()
|
|
63
|
+
resetInfiniteScroll.mockClear()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('calls router.replace once from an effect when redirect.url is present', async () => {
|
|
67
|
+
useProductGalleryQuery.mockReturnValue({
|
|
68
|
+
data: { redirect: { url: '/office' }, search: { products: null } },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await act(async () => {
|
|
72
|
+
render(
|
|
73
|
+
<SearchWrapper
|
|
74
|
+
itemsPerPage={12}
|
|
75
|
+
searchContentType={{} as never}
|
|
76
|
+
serverData={{ searchTerm: 'refil' } as never}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(replace).toHaveBeenCalledWith('/office')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not call router.replace again for the same redirect url on rerender', async () => {
|
|
88
|
+
useProductGalleryQuery.mockReturnValue({
|
|
89
|
+
data: { redirect: { url: '/office' }, search: { products: null } },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const { rerender } = render(
|
|
93
|
+
<SearchWrapper
|
|
94
|
+
itemsPerPage={12}
|
|
95
|
+
searchContentType={{} as never}
|
|
96
|
+
serverData={{ searchTerm: 'refil' } as never}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
rerender(
|
|
106
|
+
<SearchWrapper
|
|
107
|
+
itemsPerPage={12}
|
|
108
|
+
searchContentType={{} as never}
|
|
109
|
+
serverData={{ searchTerm: 'refil' } as never}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not redirect when there is no redirect url', async () => {
|
|
118
|
+
useProductGalleryQuery.mockReturnValue({
|
|
119
|
+
data: {
|
|
120
|
+
redirect: null,
|
|
121
|
+
search: { products: { pageInfo: { totalCount: 12 } } },
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
render(
|
|
127
|
+
<SearchWrapper
|
|
128
|
+
itemsPerPage={12}
|
|
129
|
+
searchContentType={{} as never}
|
|
130
|
+
serverData={{ searchTerm: 'desk' } as never}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(replace).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, render, screen } from '@testing-library/react'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
vi.mock('@faststore/sdk', () => ({
|
|
9
|
+
useSearch: () => ({
|
|
10
|
+
state: {
|
|
11
|
+
term: null,
|
|
12
|
+
sort: 'score_desc',
|
|
13
|
+
selectedFacets: [],
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import GalleryPageHeightLock, {
|
|
19
|
+
buildGalleryPageHeightKey,
|
|
20
|
+
getGalleryViewportBucket,
|
|
21
|
+
getReservedGalleryHeight,
|
|
22
|
+
readGalleryPageHeight,
|
|
23
|
+
resolveReservedGalleryHeight,
|
|
24
|
+
} from 'src/components/ui/ProductGallery/GalleryPageHeightLock'
|
|
25
|
+
|
|
26
|
+
describe('GalleryPageHeightLock helpers', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
sessionStorage.clear()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('maps viewport widths to layout buckets', () => {
|
|
36
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: 390,
|
|
39
|
+
})
|
|
40
|
+
expect(getGalleryViewportBucket()).toBe('mobile')
|
|
41
|
+
|
|
42
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
43
|
+
configurable: true,
|
|
44
|
+
value: 700,
|
|
45
|
+
})
|
|
46
|
+
expect(getGalleryViewportBucket()).toBe('tablet')
|
|
47
|
+
|
|
48
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
49
|
+
configurable: true,
|
|
50
|
+
value: 1024,
|
|
51
|
+
})
|
|
52
|
+
expect(getGalleryViewportBucket()).toBe('notebook')
|
|
53
|
+
|
|
54
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
55
|
+
configurable: true,
|
|
56
|
+
value: 1440,
|
|
57
|
+
})
|
|
58
|
+
expect(getGalleryViewportBucket()).toBe('desktop')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('builds stable storage keys that include viewport', () => {
|
|
62
|
+
const keyA = buildGalleryPageHeightKey(2, {
|
|
63
|
+
path: '/office',
|
|
64
|
+
term: null,
|
|
65
|
+
sort: 'score_desc',
|
|
66
|
+
selectedFacets: [],
|
|
67
|
+
viewport: 'desktop',
|
|
68
|
+
})
|
|
69
|
+
const keyB = buildGalleryPageHeightKey(2, {
|
|
70
|
+
path: '/office',
|
|
71
|
+
term: null,
|
|
72
|
+
sort: 'score_desc',
|
|
73
|
+
selectedFacets: [],
|
|
74
|
+
viewport: 'mobile',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(keyA).toMatch(/^__fs_gallery_page_h_p2_desktop_/)
|
|
78
|
+
expect(keyB).toMatch(/^__fs_gallery_page_h_p2_mobile_/)
|
|
79
|
+
expect(keyA).not.toBe(keyB)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps page and viewport distinct for long path + facets', () => {
|
|
83
|
+
// Truncating a sanitized JSON at 180 chars used to drop page/viewport and
|
|
84
|
+
// collide mobile↔desktop (and page 0↔1) on filtered PLPs.
|
|
85
|
+
const search = {
|
|
86
|
+
path: '/eletronicos/celulares-e-smartphones',
|
|
87
|
+
term: null,
|
|
88
|
+
sort: 'score_desc',
|
|
89
|
+
selectedFacets: [
|
|
90
|
+
{ key: 'brand', value: 'samsung' },
|
|
91
|
+
{ key: 'price', value: '1000-to-2000' },
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mobile = buildGalleryPageHeightKey(0, {
|
|
96
|
+
...search,
|
|
97
|
+
viewport: 'mobile',
|
|
98
|
+
})
|
|
99
|
+
const desktop = buildGalleryPageHeightKey(0, {
|
|
100
|
+
...search,
|
|
101
|
+
viewport: 'desktop',
|
|
102
|
+
})
|
|
103
|
+
const page1 = buildGalleryPageHeightKey(1, {
|
|
104
|
+
...search,
|
|
105
|
+
viewport: 'desktop',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(mobile).not.toBe(desktop)
|
|
109
|
+
expect(desktop).not.toBe(page1)
|
|
110
|
+
expect(mobile).toContain('_mobile_')
|
|
111
|
+
expect(desktop).toContain('_desktop_')
|
|
112
|
+
expect(page1).toMatch(/^__fs_gallery_page_h_p1_/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reads and sums reserved gallery heights', () => {
|
|
116
|
+
const search = {
|
|
117
|
+
path: '/office',
|
|
118
|
+
term: null,
|
|
119
|
+
sort: 'score_desc',
|
|
120
|
+
selectedFacets: [],
|
|
121
|
+
viewport: 'desktop',
|
|
122
|
+
}
|
|
123
|
+
const key0 = buildGalleryPageHeightKey(0, search)
|
|
124
|
+
const key1 = buildGalleryPageHeightKey(1, search)
|
|
125
|
+
sessionStorage.setItem(key0, '800')
|
|
126
|
+
sessionStorage.setItem(key1, '750')
|
|
127
|
+
|
|
128
|
+
expect(readGalleryPageHeight(key0)).toBe(800)
|
|
129
|
+
expect(readGalleryPageHeight('missing')).toBeNull()
|
|
130
|
+
expect(getReservedGalleryHeight([0, 1, 2], search)).toBe(1550)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('ignores invalid stored heights', () => {
|
|
134
|
+
sessionStorage.setItem('bad-key', '0')
|
|
135
|
+
expect(readGalleryPageHeight('bad-key')).toBeNull()
|
|
136
|
+
sessionStorage.setItem('nan-key', 'nope')
|
|
137
|
+
expect(readGalleryPageHeight('nan-key')).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('resolveReservedGalleryHeight is zero once products have loaded', () => {
|
|
141
|
+
const search = {
|
|
142
|
+
path: '/office',
|
|
143
|
+
term: null,
|
|
144
|
+
sort: 'score_desc',
|
|
145
|
+
selectedFacets: [],
|
|
146
|
+
viewport: 'desktop',
|
|
147
|
+
}
|
|
148
|
+
sessionStorage.setItem(buildGalleryPageHeightKey(0, search), '800')
|
|
149
|
+
expect(resolveReservedGalleryHeight(true, [0], search)).toBe(0)
|
|
150
|
+
expect(resolveReservedGalleryHeight(false, [0], search)).toBe(800)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('GalleryPageHeightLock', () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
sessionStorage.clear()
|
|
157
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
158
|
+
configurable: true,
|
|
159
|
+
value: 1440,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('applies minHeight while skeleton is showing when a height was saved', async () => {
|
|
164
|
+
const key = buildGalleryPageHeightKey(0, {
|
|
165
|
+
path: '/',
|
|
166
|
+
term: null,
|
|
167
|
+
sort: 'score_desc',
|
|
168
|
+
selectedFacets: [],
|
|
169
|
+
viewport: 'desktop',
|
|
170
|
+
})
|
|
171
|
+
// Component uses window.location.pathname — jsdom default is "/"
|
|
172
|
+
sessionStorage.setItem(key, '640')
|
|
173
|
+
|
|
174
|
+
const { container } = render(
|
|
175
|
+
<GalleryPageHeightLock page={0}>
|
|
176
|
+
<div data-testid="skeleton">loading</div>
|
|
177
|
+
</GalleryPageHeightLock>
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const lock = container.querySelector(
|
|
181
|
+
'[data-fs-gallery-page-height-lock]'
|
|
182
|
+
) as HTMLElement
|
|
183
|
+
|
|
184
|
+
// Height is applied after mount (hydration-safe).
|
|
185
|
+
await act(async () => {
|
|
186
|
+
await Promise.resolve()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(lock.style.minHeight).toBe('640px')
|
|
190
|
+
expect(screen.getByTestId('skeleton')).toBeTruthy()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('releases minHeight once product cards are mounted', async () => {
|
|
194
|
+
const key = buildGalleryPageHeightKey(0, {
|
|
195
|
+
path: '/',
|
|
196
|
+
term: null,
|
|
197
|
+
sort: 'score_desc',
|
|
198
|
+
selectedFacets: [],
|
|
199
|
+
viewport: 'desktop',
|
|
200
|
+
})
|
|
201
|
+
sessionStorage.setItem(key, '640')
|
|
202
|
+
|
|
203
|
+
const { container, rerender } = render(
|
|
204
|
+
<GalleryPageHeightLock page={0}>
|
|
205
|
+
<div data-testid="skeleton">loading</div>
|
|
206
|
+
</GalleryPageHeightLock>
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await Promise.resolve()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const lock = container.querySelector(
|
|
214
|
+
'[data-fs-gallery-page-height-lock]'
|
|
215
|
+
) as HTMLElement
|
|
216
|
+
expect(lock.style.minHeight).toBe('640px')
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
rerender(
|
|
220
|
+
<GalleryPageHeightLock page={0}>
|
|
221
|
+
<div data-fs-product-card data-testid="card">
|
|
222
|
+
Product
|
|
223
|
+
</div>
|
|
224
|
+
</GalleryPageHeightLock>
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(lock.style.minHeight).toBe('')
|
|
229
|
+
})
|
|
230
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createBaseStore } from '@faststore/sdk'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
SESSION_VALIDATION_WAIT_MS,
|
|
5
|
+
waitForSessionValidated,
|
|
6
|
+
} from '../../../src/sdk/cart/waitForSessionValidated'
|
|
7
|
+
|
|
8
|
+
describe('waitForSessionValidated', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('resolves immediately when session was already validated', async () => {
|
|
14
|
+
const store = createBaseStore(true)
|
|
15
|
+
|
|
16
|
+
await expect(waitForSessionValidated(store)).resolves.toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('resolves when session becomes validated after subscribe', async () => {
|
|
20
|
+
const store = createBaseStore(false)
|
|
21
|
+
|
|
22
|
+
const pending = waitForSessionValidated(store, 5_000)
|
|
23
|
+
store.set(true)
|
|
24
|
+
|
|
25
|
+
await expect(pending).resolves.toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('ignores subscribe callbacks while validated is still false', async () => {
|
|
29
|
+
vi.useFakeTimers()
|
|
30
|
+
const listeners: Array<(value: boolean) => void> = []
|
|
31
|
+
const store = {
|
|
32
|
+
read: vi.fn(() => false),
|
|
33
|
+
readInitial: vi.fn(() => false),
|
|
34
|
+
subscribe: vi.fn((listener: (value: boolean) => void) => {
|
|
35
|
+
listeners.push(listener)
|
|
36
|
+
return () => {
|
|
37
|
+
const index = listeners.indexOf(listener)
|
|
38
|
+
if (index >= 0) {
|
|
39
|
+
listeners.splice(index, 1)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}),
|
|
43
|
+
set: vi.fn(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const pending = waitForSessionValidated(store, SESSION_VALIDATION_WAIT_MS)
|
|
47
|
+
|
|
48
|
+
// Emit false — should not resolve yet
|
|
49
|
+
for (const listener of listeners) {
|
|
50
|
+
listener(false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await vi.advanceTimersByTimeAsync(SESSION_VALIDATION_WAIT_MS)
|
|
54
|
+
await expect(pending).resolves.toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('resolves on timeout when session never validates', async () => {
|
|
58
|
+
vi.useFakeTimers()
|
|
59
|
+
const store = createBaseStore(false)
|
|
60
|
+
|
|
61
|
+
const pending = waitForSessionValidated(store, SESSION_VALIDATION_WAIT_MS)
|
|
62
|
+
|
|
63
|
+
await vi.advanceTimersByTimeAsync(SESSION_VALIDATION_WAIT_MS)
|
|
64
|
+
|
|
65
|
+
await expect(pending).resolves.toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves the race when validation finishes between read and subscribe', async () => {
|
|
69
|
+
let current = false
|
|
70
|
+
const listeners: Array<(value: boolean) => void> = []
|
|
71
|
+
|
|
72
|
+
const store = {
|
|
73
|
+
read: vi.fn(() => {
|
|
74
|
+
// First read: not validated. Second read (post-subscribe): validated.
|
|
75
|
+
const value = current
|
|
76
|
+
current = true
|
|
77
|
+
return value
|
|
78
|
+
}),
|
|
79
|
+
readInitial: vi.fn(() => false),
|
|
80
|
+
subscribe: vi.fn((listener: (value: boolean) => void) => {
|
|
81
|
+
listeners.push(listener)
|
|
82
|
+
return () => {
|
|
83
|
+
const index = listeners.indexOf(listener)
|
|
84
|
+
if (index >= 0) {
|
|
85
|
+
listeners.splice(index, 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
set: vi.fn(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await expect(waitForSessionValidated(store, 5_000)).resolves.toBeUndefined()
|
|
93
|
+
expect(store.subscribe).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(store.read).toHaveBeenCalledTimes(2)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { Session } from '@faststore/sdk'
|
|
3
|
+
import { syncSalesChannelFromOrderForm } from '../../../src/sdk/session/syncSalesChannelFromOrderForm'
|
|
4
|
+
|
|
5
|
+
const baseSession = (
|
|
6
|
+
salesChannel: string,
|
|
7
|
+
explicit = true,
|
|
8
|
+
channelOverride?: string | null
|
|
9
|
+
): Session => ({
|
|
10
|
+
currency: { code: 'BRL', symbol: 'R$' },
|
|
11
|
+
locale: 'pt-BR',
|
|
12
|
+
country: 'BRA',
|
|
13
|
+
channel:
|
|
14
|
+
channelOverride !== undefined
|
|
15
|
+
? channelOverride
|
|
16
|
+
: JSON.stringify({
|
|
17
|
+
salesChannel,
|
|
18
|
+
regionId: '',
|
|
19
|
+
hasOnlyDefaultSalesChannel: !explicit,
|
|
20
|
+
}),
|
|
21
|
+
deliveryMode: null,
|
|
22
|
+
addressType: null,
|
|
23
|
+
city: null,
|
|
24
|
+
postalCode: null,
|
|
25
|
+
geoCoordinates: null,
|
|
26
|
+
person: null,
|
|
27
|
+
b2b: null,
|
|
28
|
+
marketingData: null,
|
|
29
|
+
refreshAfter: null,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('syncSalesChannelFromOrderForm', () => {
|
|
33
|
+
it('updates session channel silently when SC diverges', () => {
|
|
34
|
+
const setSilent = vi.fn()
|
|
35
|
+
const synced = syncSalesChannelFromOrderForm(
|
|
36
|
+
'2',
|
|
37
|
+
() => baseSession('1', false),
|
|
38
|
+
setSilent
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(synced).toBe(true)
|
|
42
|
+
expect(setSilent).toHaveBeenCalledTimes(1)
|
|
43
|
+
const next = setSilent.mock.calls[0][0] as Session
|
|
44
|
+
expect(JSON.parse(next.channel ?? '{}')).toMatchObject({
|
|
45
|
+
salesChannel: '2',
|
|
46
|
+
hasOnlyDefaultSalesChannel: false,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('is a no-op when SC already matches', () => {
|
|
51
|
+
const setSilent = vi.fn()
|
|
52
|
+
const synced = syncSalesChannelFromOrderForm(
|
|
53
|
+
'2',
|
|
54
|
+
() => baseSession('2'),
|
|
55
|
+
setSilent
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(synced).toBe(false)
|
|
59
|
+
expect(setSilent).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('is a no-op when adopted SC is missing', () => {
|
|
63
|
+
const setSilent = vi.fn()
|
|
64
|
+
expect(
|
|
65
|
+
syncSalesChannelFromOrderForm(null, () => baseSession('1'), setSilent)
|
|
66
|
+
).toBe(false)
|
|
67
|
+
expect(
|
|
68
|
+
syncSalesChannelFromOrderForm('', () => baseSession('1'), setSilent)
|
|
69
|
+
).toBe(false)
|
|
70
|
+
expect(setSilent).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('resets non-object channel JSON before adopting SC', () => {
|
|
74
|
+
const setSilent = vi.fn()
|
|
75
|
+
|
|
76
|
+
for (const invalid of ['null', '[]', '"text"', '0', 'false']) {
|
|
77
|
+
setSilent.mockClear()
|
|
78
|
+
const synced = syncSalesChannelFromOrderForm(
|
|
79
|
+
'2',
|
|
80
|
+
() => baseSession('1', true, invalid),
|
|
81
|
+
setSilent
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(synced).toBe(true)
|
|
85
|
+
expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toMatchObject({
|
|
86
|
+
salesChannel: '2',
|
|
87
|
+
hasOnlyDefaultSalesChannel: false,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('resets malformed channel JSON before adopting SC', () => {
|
|
93
|
+
const setSilent = vi.fn()
|
|
94
|
+
const synced = syncSalesChannelFromOrderForm(
|
|
95
|
+
'2',
|
|
96
|
+
() => baseSession('1', true, '{'),
|
|
97
|
+
setSilent
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(synced).toBe(true)
|
|
101
|
+
expect(JSON.parse(setSilent.mock.calls[0][0].channel)).toMatchObject({
|
|
102
|
+
salesChannel: '2',
|
|
103
|
+
hasOnlyDefaultSalesChannel: false,
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('ignores object-valued salesChannel when comparing', () => {
|
|
108
|
+
const setSilent = vi.fn()
|
|
109
|
+
const synced = syncSalesChannelFromOrderForm(
|
|
110
|
+
'2',
|
|
111
|
+
() =>
|
|
112
|
+
baseSession(
|
|
113
|
+
'1',
|
|
114
|
+
true,
|
|
115
|
+
JSON.stringify({ salesChannel: { nested: true } })
|
|
116
|
+
),
|
|
117
|
+
setSilent
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(synced).toBe(true)
|
|
121
|
+
expect(JSON.parse(setSilent.mock.calls[0][0].channel).salesChannel).toBe(
|
|
122
|
+
'2'
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
})
|