@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.
@@ -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
+ })