@faststore/core 4.9.1-dev.0 → 4.9.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 +158 -144
- package/@generated/graphql.ts +1 -1
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +10 -2
- package/package.json +6 -6
- package/src/components/region/RegionModal/RegionModal.tsx +1 -1
- package/src/components/region/RegionModal/useRegion.ts +3 -2
- package/src/components/region/RegionPopover/RegionPopover.tsx +1 -1
- package/src/components/region/RegionSlider/RegionSlider.tsx +1 -1
- package/src/pages/[slug]/p.tsx +7 -6
- package/src/sdk/account/useReorder.ts +2 -2
- package/src/sdk/cart/index.ts +8 -2
- package/src/sdk/session/index.ts +19 -19
- package/src/sdk/session/toSessionInput.ts +27 -0
- package/src/utils/productJsonLd.ts +100 -1
- package/test/components/region/useRegion.browser.test.ts +64 -0
- package/test/sdk/session/sessionStore.browser.test.ts +63 -0
- package/test/sdk/session/toSessionInput.test.ts +43 -0
- package/test/sdk/session/validateSession.browser.test.ts +47 -0
- package/test/utils/productJsonLd.test.ts +101 -1
- package/test/utils/productJsonLdRendering.test.tsx +24 -4
|
@@ -3,7 +3,7 @@ import { useState } from 'react'
|
|
|
3
3
|
import type { Session } from '@faststore/sdk'
|
|
4
4
|
import { deliveryPromise } from 'discovery.config'
|
|
5
5
|
import { getProductCount } from 'src/sdk/product'
|
|
6
|
-
import { sessionStore, validateSession } from 'src/sdk/session'
|
|
6
|
+
import { sessionStore, toSessionInput, validateSession } from 'src/sdk/session'
|
|
7
7
|
|
|
8
8
|
type SetRegionProps = {
|
|
9
9
|
session: Session
|
|
@@ -40,8 +40,9 @@ export default function useRegion(): UseRegionValues {
|
|
|
40
40
|
setLoading(true)
|
|
41
41
|
|
|
42
42
|
try {
|
|
43
|
+
// `session` usually comes from `useSession()`, which also carries UI state
|
|
43
44
|
const newSession = {
|
|
44
|
-
...session,
|
|
45
|
+
...toSessionInput(session),
|
|
45
46
|
postalCode,
|
|
46
47
|
geoCoordinates: null, // Revalidate geo coordinates in API when users set a new postal code
|
|
47
48
|
} as Session
|
|
@@ -78,7 +78,7 @@ function RegionPopover(regionPopoverProps: RegionPopoverProps) {
|
|
|
78
78
|
} = cmsData?.regionalization ?? {}
|
|
79
79
|
|
|
80
80
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
81
|
-
const { isValidating,
|
|
81
|
+
const { isValidating, ...session } = useSession()
|
|
82
82
|
const { popover: displayPopover, closePopover } = useUI()
|
|
83
83
|
const { onPostalCodeChange } = useDeliveryPromise()
|
|
84
84
|
const { city, postalCode } = sessionStore.read()
|
|
@@ -45,7 +45,7 @@ function RegionSlider() {
|
|
|
45
45
|
regionSlider: { type: regionSliderType, isOpen },
|
|
46
46
|
closeRegionSlider,
|
|
47
47
|
} = useUI()
|
|
48
|
-
const { isValidating,
|
|
48
|
+
const { isValidating, ...session } = useSession()
|
|
49
49
|
const { state: searchState, setState: setSearchState } = useSearch()
|
|
50
50
|
const {
|
|
51
51
|
loading: loadingRegion,
|
package/src/pages/[slug]/p.tsx
CHANGED
|
@@ -30,7 +30,10 @@ import { execute } from 'src/server'
|
|
|
30
30
|
import { getComponentKey } from 'src/utils/cms'
|
|
31
31
|
import { getChannelForLocale } from 'src/utils/localization/bindingPaths'
|
|
32
32
|
import { buildHreflangLinks } from 'src/utils/localization/hreflang'
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
toProductJsonLdIdentifiers,
|
|
35
|
+
toProductJsonLdOffer,
|
|
36
|
+
} from 'src/utils/productJsonLd'
|
|
34
37
|
|
|
35
38
|
import storeConfig from 'discovery.config'
|
|
36
39
|
import {
|
|
@@ -295,11 +298,9 @@ function Page({
|
|
|
295
298
|
description={description}
|
|
296
299
|
brand={product.brand.name}
|
|
297
300
|
sku={product.sku}
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
{...(product
|
|
301
|
-
{...(product.mpn && { mpn: product.mpn })}
|
|
302
|
-
{...(product.releaseDate && { releaseDate: product.releaseDate })}
|
|
301
|
+
// Omits empty identifiers and normalizes releaseDate to ISO 8601; see
|
|
302
|
+
// toProductJsonLdIdentifiers.
|
|
303
|
+
{...toProductJsonLdIdentifiers(product)}
|
|
303
304
|
images={product.image.map((img) => img.url)} // Somehow, Google does not understand this valid Schema.org schema, so we need to do conversions
|
|
304
305
|
{...(offers && { offers })}
|
|
305
306
|
{...(itemListElements.length !== 0 && {
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from '@generated/graphql'
|
|
11
11
|
import ReorderError from '../error/ReorderError/ReorderError'
|
|
12
12
|
import { request } from '../graphql/request'
|
|
13
|
-
import { sessionStore } from '../session'
|
|
13
|
+
import { sessionStore, toSessionInput } from '../session'
|
|
14
14
|
import { useLink } from '../ui/useLink'
|
|
15
15
|
|
|
16
16
|
import storeConfig from '../../../discovery.config'
|
|
@@ -116,7 +116,7 @@ export const useReorder = () => {
|
|
|
116
116
|
ValidateCartMutationMutation,
|
|
117
117
|
ValidateCartMutationMutationVariables
|
|
118
118
|
>(ValidateCartMutation, {
|
|
119
|
-
session: sessionStore.read(),
|
|
119
|
+
session: toSessionInput(sessionStore.read()),
|
|
120
120
|
cart: {
|
|
121
121
|
order: orderPayload as IStoreCart['order'],
|
|
122
122
|
},
|
package/src/sdk/cart/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
import storeConfig from '../../../discovery.config'
|
|
15
15
|
import { request } from '../graphql/request'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
hasValidatedSessionStore,
|
|
18
|
+
sessionStore,
|
|
19
|
+
toSessionInput,
|
|
20
|
+
} from '../session'
|
|
17
21
|
import { syncSalesChannelFromOrderForm } from '../session/syncSalesChannelFromOrderForm'
|
|
18
22
|
import { createValidationStore, useStore } from '../useStore'
|
|
19
23
|
import { waitForSessionValidated } from './waitForSessionValidated'
|
|
@@ -123,7 +127,9 @@ const validateCart = async (cart: Cart): Promise<Cart | null> => {
|
|
|
123
127
|
ValidateCartMutationMutation,
|
|
124
128
|
ValidateCartMutationMutationVariables
|
|
125
129
|
>(ValidateCartMutation, {
|
|
126
|
-
session
|
|
130
|
+
// The session loaded from IndexedDB skips `sessionStore.set`, so it may
|
|
131
|
+
// still carry UI state persisted by an older version
|
|
132
|
+
session: toSessionInput(sessionStore.read()),
|
|
127
133
|
cart: {
|
|
128
134
|
order: {
|
|
129
135
|
orderNumber: cart.id,
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -21,6 +21,9 @@ import { createValidationStore, useStore } from '../useStore'
|
|
|
21
21
|
import { getPostalCode } from '../userLocation/index'
|
|
22
22
|
import { getInitialSession, reconcileSessionLocale } from './initialSession'
|
|
23
23
|
import { RELOAD_AFTER_LOGOUT_KEY, SESSION_READY_KEY } from './storageKeys'
|
|
24
|
+
import { type SessionUIKey, toSessionInput } from './toSessionInput'
|
|
25
|
+
|
|
26
|
+
export { toSessionInput } from './toSessionInput'
|
|
24
27
|
|
|
25
28
|
const isReloadAfterLogoutPending = (): boolean => {
|
|
26
29
|
try {
|
|
@@ -160,18 +163,11 @@ export const validateSession = async (session: Session) => {
|
|
|
160
163
|
return null
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
// Remove fields that are not part of IStoreSession type
|
|
164
|
-
const { isSessionReady, isValidating, ...sessionWithoutExtras } =
|
|
165
|
-
session as Session & {
|
|
166
|
-
isSessionReady?: boolean
|
|
167
|
-
isValidating?: boolean
|
|
168
|
-
}
|
|
169
|
-
|
|
170
166
|
const data = await request<
|
|
171
167
|
ValidateSessionMutation,
|
|
172
168
|
ValidateSessionMutationVariables
|
|
173
169
|
>(mutation, {
|
|
174
|
-
session:
|
|
170
|
+
session: toSessionInput(session),
|
|
175
171
|
search: window.location.search,
|
|
176
172
|
})
|
|
177
173
|
|
|
@@ -210,9 +206,11 @@ const defaultStore = createSessionStore(
|
|
|
210
206
|
export const sessionStore = {
|
|
211
207
|
...defaultStore,
|
|
212
208
|
set: (val: Session) => {
|
|
213
|
-
|
|
209
|
+
// Also drops UI state already persisted, since `session` then differs
|
|
210
|
+
const session = toSessionInput(val)
|
|
211
|
+
if (deepEqual(session, defaultStore.read()) === true) return
|
|
214
212
|
|
|
215
|
-
defaultStore.set(
|
|
213
|
+
defaultStore.set(session)
|
|
216
214
|
|
|
217
215
|
// Trigger cart revalidation when session changes
|
|
218
216
|
cartStore.set(cartStore.read())
|
|
@@ -224,9 +222,10 @@ export const sessionStore = {
|
|
|
224
222
|
* path keeps an explicit (non-default) client SC.
|
|
225
223
|
*/
|
|
226
224
|
setSilent: (val: Session) => {
|
|
227
|
-
|
|
225
|
+
const session = toSessionInput(val)
|
|
226
|
+
if (deepEqual(session, defaultStore.read()) === true) return
|
|
228
227
|
|
|
229
|
-
defaultStore.set(
|
|
228
|
+
defaultStore.set(session)
|
|
230
229
|
},
|
|
231
230
|
}
|
|
232
231
|
|
|
@@ -276,16 +275,17 @@ export const useSession = ({ filter }: SessionOptions = { filter: true }) => {
|
|
|
276
275
|
channel = filterChannel(channel ?? '')
|
|
277
276
|
}
|
|
278
277
|
|
|
279
|
-
return useMemo(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
return useMemo(() => {
|
|
279
|
+
// Must match `SESSION_UI_KEYS`, which `toSessionInput` strips before the
|
|
280
|
+
// session is stored or sent to the API
|
|
281
|
+
const uiState = {
|
|
283
282
|
isValidating,
|
|
284
283
|
isSessionReady,
|
|
285
284
|
hasValidated,
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
285
|
+
} satisfies Record<SessionUIKey, boolean>
|
|
286
|
+
|
|
287
|
+
return { ...session, channel, ...uiState }
|
|
288
|
+
}, [isValidating, session, channel, isSessionReady, hasValidated])
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
|
|
3
|
+
/** UI-only state that `useSession()` returns next to the session fields. */
|
|
4
|
+
export const SESSION_UI_KEYS = [
|
|
5
|
+
'isSessionReady',
|
|
6
|
+
'isValidating',
|
|
7
|
+
'hasValidated',
|
|
8
|
+
] as const
|
|
9
|
+
|
|
10
|
+
export type SessionUIKey = (typeof SESSION_UI_KEYS)[number]
|
|
11
|
+
|
|
12
|
+
type SessionUIState = Partial<Record<SessionUIKey, boolean>>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the session without the UI-only state, as `IStoreSession` expects.
|
|
16
|
+
* GraphQL rejects unknown input fields, so a session that carries them (e.g.
|
|
17
|
+
* built from `useSession()` or persisted that way) fails every validation.
|
|
18
|
+
*/
|
|
19
|
+
export function toSessionInput(session: Session & SessionUIState): Session {
|
|
20
|
+
const input = { ...session }
|
|
21
|
+
|
|
22
|
+
for (const key of SESSION_UI_KEYS) {
|
|
23
|
+
delete input[key]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return input
|
|
27
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ServerProductQueryQuery } from '@generated/graphql'
|
|
2
2
|
|
|
3
|
-
type
|
|
3
|
+
type ServerProduct = ServerProductQueryQuery['product']
|
|
4
|
+
type ServerOffers = ServerProduct['offers']
|
|
4
5
|
type ServerOffer = ServerOffers['offers'][number]
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -69,3 +70,101 @@ export const toProductJsonLdOffer = (
|
|
|
69
70
|
|
|
70
71
|
return offer
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
// Epoch values are disambiguated by magnitude, not digit count: anything at or
|
|
75
|
+
// above this is milliseconds (>= 1973-03-03), anything below is seconds. A
|
|
76
|
+
// millisecond value under the threshold is read as seconds — accepted, since no
|
|
77
|
+
// real catalog carries a release date that early, and a digit-count heuristic
|
|
78
|
+
// leaves a strictly larger hole (9-digit seconds and 12-digit milliseconds both
|
|
79
|
+
// fall through to the string parser, which reads them as a year).
|
|
80
|
+
const MILLISECONDS_THRESHOLD = 1e11
|
|
81
|
+
|
|
82
|
+
const isAllDigits = (value: string) => /^\d+$/.test(value)
|
|
83
|
+
|
|
84
|
+
// An ISO date-time with no timezone designator. `new Date()` reads these as
|
|
85
|
+
// host-local time (date-only forms are already read as UTC), which would make
|
|
86
|
+
// the emitted calendar day depend on the build machine's zone.
|
|
87
|
+
const ISO_DATE_TIME_WITHOUT_ZONE =
|
|
88
|
+
/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/
|
|
89
|
+
|
|
90
|
+
const toDate = (value: string): Date => {
|
|
91
|
+
if (!isAllDigits(value)) {
|
|
92
|
+
return new Date(
|
|
93
|
+
ISO_DATE_TIME_WITHOUT_ZONE.test(value) ? `${value}Z` : value
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const epoch = Number(value)
|
|
98
|
+
|
|
99
|
+
return new Date(epoch >= MILLISECONDS_THRESHOLD ? epoch : epoch * 1000)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalizes `StoreProduct.releaseDate` to the ISO 8601 calendar date
|
|
104
|
+
* (`YYYY-MM-DD`) Schema.org expects for the PDP's `ProductJsonLd`.
|
|
105
|
+
*
|
|
106
|
+
* The API passes the field through as Intelligent Search delivers it — epoch
|
|
107
|
+
* milliseconds for most accounts, an ISO string for others — because stores
|
|
108
|
+
* read that raw value in their own customizations. Normalizing here, and only
|
|
109
|
+
* here, keeps the structured data valid without changing that contract.
|
|
110
|
+
*
|
|
111
|
+
* Accepts epoch milliseconds, epoch seconds, and date strings. Returns an empty
|
|
112
|
+
* string when the input is absent or unparseable — never throws, and never
|
|
113
|
+
* returns `"Invalid Date"`.
|
|
114
|
+
*
|
|
115
|
+
* The calendar day is always taken in UTC, so a build produces the same markup
|
|
116
|
+
* on any machine. An input carrying a timezone offset is converted first, which
|
|
117
|
+
* means `2026-03-23T21:00:00-05:00` normalizes to `2026-03-24`. That is
|
|
118
|
+
* deliberate: preserving the source calendar day would make the output depend on
|
|
119
|
+
* the offset embedded in each record. An input with no timezone designator is
|
|
120
|
+
* read as UTC rather than as host-local time, for the same reason.
|
|
121
|
+
*/
|
|
122
|
+
export const toProductJsonLdReleaseDate = (
|
|
123
|
+
value: string | number | null | undefined
|
|
124
|
+
): string => {
|
|
125
|
+
if (value === null || value === undefined) {
|
|
126
|
+
return ''
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const raw = String(value).trim()
|
|
130
|
+
|
|
131
|
+
if (raw === '') {
|
|
132
|
+
return ''
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const date = toDate(raw)
|
|
136
|
+
|
|
137
|
+
if (Number.isNaN(date.getTime())) {
|
|
138
|
+
return ''
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return date.toISOString().slice(0, 10)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Builds the identifier props (`gtin`, `mpn`, `releaseDate`) for the PDP's
|
|
146
|
+
* `ProductJsonLd`, ready to spread.
|
|
147
|
+
*
|
|
148
|
+
* Every key is omitted when its value is empty, because next-seo publishes
|
|
149
|
+
* props untouched and an empty string is invalid Schema.org. `releaseDate` is
|
|
150
|
+
* normalized here because `StoreProduct.releaseDate` is the raw upstream value
|
|
151
|
+
* (usually epoch milliseconds), which Schema.org consumers reject.
|
|
152
|
+
*
|
|
153
|
+
* This lives here rather than inline in `p.tsx` so the rendering tests exercise
|
|
154
|
+
* the same code the page runs: passing `product.releaseDate` straight through
|
|
155
|
+
* would publish `"1774224000000"` and still leave a unit test of the
|
|
156
|
+
* normalizer green.
|
|
157
|
+
*/
|
|
158
|
+
export const toProductJsonLdIdentifiers = ({
|
|
159
|
+
gtin,
|
|
160
|
+
mpn,
|
|
161
|
+
releaseDate,
|
|
162
|
+
}: Pick<ServerProduct, 'gtin' | 'mpn' | 'releaseDate'>) => {
|
|
163
|
+
const normalizedReleaseDate = toProductJsonLdReleaseDate(releaseDate)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
...(gtin && { gtin }),
|
|
167
|
+
...(mpn && { mpn }),
|
|
168
|
+
...(normalizedReleaseDate && { releaseDate: normalizedReleaseDate }),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const mockValidateSession = vi.hoisted(() => vi.fn())
|
|
6
|
+
const mockSessionSet = vi.hoisted(() => vi.fn())
|
|
7
|
+
|
|
8
|
+
vi.mock('src/sdk/session', async () => ({
|
|
9
|
+
validateSession: mockValidateSession,
|
|
10
|
+
sessionStore: { set: mockSessionSet },
|
|
11
|
+
toSessionInput: (await import('src/sdk/session/toSessionInput'))
|
|
12
|
+
.toSessionInput,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('src/sdk/product', () => ({
|
|
16
|
+
getProductCount: vi.fn().mockResolvedValue(1),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import useRegion from '../../../src/components/region/RegionModal/useRegion'
|
|
20
|
+
|
|
21
|
+
const session: Session = {
|
|
22
|
+
locale: 'en-US',
|
|
23
|
+
currency: { code: 'USD', symbol: '$' },
|
|
24
|
+
country: 'USA',
|
|
25
|
+
channel: '{"salesChannel":"1","regionId":""}',
|
|
26
|
+
deliveryMode: null,
|
|
27
|
+
addressType: null,
|
|
28
|
+
city: null,
|
|
29
|
+
postalCode: null,
|
|
30
|
+
geoCoordinates: null,
|
|
31
|
+
person: null,
|
|
32
|
+
b2b: null,
|
|
33
|
+
marketingData: null,
|
|
34
|
+
refreshAfter: null,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('useRegion', () => {
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.clearAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps the UI state from useSession() out of the new session', async () => {
|
|
43
|
+
mockValidateSession.mockResolvedValue(null)
|
|
44
|
+
const { result } = renderHook(() => useRegion())
|
|
45
|
+
|
|
46
|
+
await act(() =>
|
|
47
|
+
result.current.setRegion({
|
|
48
|
+
session: {
|
|
49
|
+
...session,
|
|
50
|
+
isValidating: false,
|
|
51
|
+
isSessionReady: true,
|
|
52
|
+
hasValidated: true,
|
|
53
|
+
} as Session,
|
|
54
|
+
postalCode: '10001',
|
|
55
|
+
errorMessage: 'error',
|
|
56
|
+
})
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const expected = { ...session, postalCode: '10001' }
|
|
60
|
+
|
|
61
|
+
expect(mockValidateSession).toHaveBeenCalledWith(expected)
|
|
62
|
+
expect(mockSessionSet).toHaveBeenCalledWith(expected)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const mockRequest = vi.hoisted(() => vi.fn())
|
|
5
|
+
|
|
6
|
+
vi.mock('../../../src/sdk/graphql/request', () => ({
|
|
7
|
+
request: mockRequest,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { sessionStore } from '../../../src/sdk/session'
|
|
11
|
+
|
|
12
|
+
const session: Session = {
|
|
13
|
+
locale: 'en-US',
|
|
14
|
+
currency: { code: 'USD', symbol: '$' },
|
|
15
|
+
country: 'USA',
|
|
16
|
+
channel: '{"salesChannel":"1","regionId":""}',
|
|
17
|
+
deliveryMode: null,
|
|
18
|
+
addressType: null,
|
|
19
|
+
city: null,
|
|
20
|
+
postalCode: '10001',
|
|
21
|
+
geoCoordinates: null,
|
|
22
|
+
person: null,
|
|
23
|
+
b2b: null,
|
|
24
|
+
marketingData: null,
|
|
25
|
+
refreshAfter: null,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const withUIState = {
|
|
29
|
+
isValidating: false,
|
|
30
|
+
isSessionReady: true,
|
|
31
|
+
hasValidated: true,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('sessionStore', () => {
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
37
|
+
// The store validates its initial session on load; let it settle first
|
|
38
|
+
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockRequest.mockClear()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not store the UI state from useSession()', () => {
|
|
46
|
+
sessionStore.set({ ...session, ...withUIState } as Session)
|
|
47
|
+
|
|
48
|
+
expect(sessionStore.read()).toStrictEqual(session)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not store the UI state on a silent update', () => {
|
|
52
|
+
sessionStore.setSilent({
|
|
53
|
+
...session,
|
|
54
|
+
postalCode: '10002',
|
|
55
|
+
...withUIState,
|
|
56
|
+
} as Session)
|
|
57
|
+
|
|
58
|
+
expect(sessionStore.read()).toStrictEqual({
|
|
59
|
+
...session,
|
|
60
|
+
postalCode: '10002',
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { toSessionInput } from '../../../src/sdk/session/toSessionInput'
|
|
5
|
+
|
|
6
|
+
const session: Session = {
|
|
7
|
+
locale: 'en-US',
|
|
8
|
+
currency: { code: 'USD', symbol: '$' },
|
|
9
|
+
country: 'USA',
|
|
10
|
+
channel: '{"salesChannel":"1","regionId":""}',
|
|
11
|
+
deliveryMode: null,
|
|
12
|
+
addressType: null,
|
|
13
|
+
city: null,
|
|
14
|
+
postalCode: '10001',
|
|
15
|
+
geoCoordinates: null,
|
|
16
|
+
person: null,
|
|
17
|
+
b2b: null,
|
|
18
|
+
marketingData: null,
|
|
19
|
+
refreshAfter: null,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('toSessionInput', () => {
|
|
23
|
+
it('drops the UI state useSession() adds, which IStoreSession rejects', () => {
|
|
24
|
+
expect(
|
|
25
|
+
toSessionInput({
|
|
26
|
+
...session,
|
|
27
|
+
isValidating: false,
|
|
28
|
+
isSessionReady: true,
|
|
29
|
+
hasValidated: true,
|
|
30
|
+
})
|
|
31
|
+
).toEqual(session)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('strips the UI state from a session persisted with it', () => {
|
|
35
|
+
const persisted = { ...session, hasValidated: true } as Session
|
|
36
|
+
|
|
37
|
+
expect(toSessionInput(persisted)).toEqual(session)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps a clean session as is', () => {
|
|
41
|
+
expect(toSessionInput(session)).toEqual(session)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const mockRequest = vi.hoisted(() => vi.fn())
|
|
5
|
+
|
|
6
|
+
vi.mock('../../../src/sdk/graphql/request', () => ({
|
|
7
|
+
request: mockRequest,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { validateSession } from '../../../src/sdk/session'
|
|
11
|
+
|
|
12
|
+
const session: Session = {
|
|
13
|
+
locale: 'en-US',
|
|
14
|
+
currency: { code: 'USD', symbol: '$' },
|
|
15
|
+
country: 'USA',
|
|
16
|
+
channel: '{"salesChannel":"1","regionId":""}',
|
|
17
|
+
deliveryMode: null,
|
|
18
|
+
addressType: null,
|
|
19
|
+
city: null,
|
|
20
|
+
postalCode: '10001',
|
|
21
|
+
geoCoordinates: null,
|
|
22
|
+
person: null,
|
|
23
|
+
b2b: null,
|
|
24
|
+
marketingData: null,
|
|
25
|
+
refreshAfter: null,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('validateSession', () => {
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
31
|
+
// The store validates its initial session on load; let it settle first
|
|
32
|
+
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
|
|
33
|
+
mockRequest.mockClear()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not send the UI state persisted with the session', async () => {
|
|
37
|
+
await validateSession({
|
|
38
|
+
...session,
|
|
39
|
+
isValidating: false,
|
|
40
|
+
isSessionReady: true,
|
|
41
|
+
hasValidated: true,
|
|
42
|
+
} as Session)
|
|
43
|
+
|
|
44
|
+
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
45
|
+
expect(mockRequest.mock.calls[0][1].session).toStrictEqual(session)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
toProductJsonLdIdentifiers,
|
|
4
|
+
toProductJsonLdOffer,
|
|
5
|
+
toProductJsonLdReleaseDate,
|
|
6
|
+
} from '../../src/utils/productJsonLd'
|
|
3
7
|
|
|
4
8
|
const OBJECT_LEVEL = { priceCurrency: 'USD', url: 'https://store.example/p' }
|
|
5
9
|
|
|
@@ -124,3 +128,99 @@ describe('toProductJsonLdOffer', () => {
|
|
|
124
128
|
).toBeUndefined()
|
|
125
129
|
})
|
|
126
130
|
})
|
|
131
|
+
|
|
132
|
+
describe('toProductJsonLdReleaseDate', () => {
|
|
133
|
+
const resolve = toProductJsonLdReleaseDate
|
|
134
|
+
|
|
135
|
+
it('converts epoch milliseconds to an ISO calendar date', () => {
|
|
136
|
+
expect(resolve('1774224000000')).toBe('2026-03-23')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('converts epoch seconds to the same date as its millisecond form', () => {
|
|
140
|
+
expect(resolve('1774224000')).toBe(resolve('1774224000000'))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('reads a value at the millisecond threshold as milliseconds', () => {
|
|
144
|
+
expect(resolve('100000000000')).toBe('1973-03-03')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('reads a value just below the threshold as seconds', () => {
|
|
148
|
+
expect(resolve('99999999999')).toBe('5138-11-16')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('accepts a numeric epoch, not only its string form', () => {
|
|
152
|
+
expect(resolve(1774224000000)).toBe('2026-03-23')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('passes an ISO date through unchanged', () => {
|
|
156
|
+
expect(resolve('2026-03-23')).toBe('2026-03-23')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('truncates an ISO datetime to its calendar date', () => {
|
|
160
|
+
expect(resolve('2026-03-23T14:30:00Z')).toBe('2026-03-23')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// UTC is deliberate: preserving the source calendar day would make the
|
|
164
|
+
// output depend on the offset embedded in each record.
|
|
165
|
+
it('resolves an offset that crosses midnight to the UTC day', () => {
|
|
166
|
+
expect(resolve('2026-03-23T21:00:00-05:00')).toBe('2026-03-24')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// `new Date(...)` reads an ISO date-time with no timezone designator as
|
|
170
|
+
// host-local, so without explicit handling this value resolves to the day
|
|
171
|
+
// before in any zone ahead of UTC. The build machine must not change the
|
|
172
|
+
// markup.
|
|
173
|
+
it('reads an ISO date-time with no timezone as UTC, not host-local', () => {
|
|
174
|
+
expect(resolve('2026-03-23T00:30:00')).toBe('2026-03-23')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('reads an end-of-day ISO date-time with no timezone as UTC', () => {
|
|
178
|
+
expect(resolve('2026-03-23T23:30:00')).toBe('2026-03-23')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it.each([
|
|
182
|
+
['an empty string', ''],
|
|
183
|
+
['whitespace', ' '],
|
|
184
|
+
['an unparseable value', 'not-a-date'],
|
|
185
|
+
['null', null],
|
|
186
|
+
['undefined', undefined],
|
|
187
|
+
])('returns an empty string for %s', (_label, input) => {
|
|
188
|
+
expect(resolve(input)).toBe('')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('never returns "Invalid Date"', () => {
|
|
192
|
+
expect(resolve('garbage')).not.toContain('Invalid')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('toProductJsonLdIdentifiers', () => {
|
|
197
|
+
it('normalizes an epoch releaseDate to an ISO calendar date', () => {
|
|
198
|
+
expect(
|
|
199
|
+
toProductJsonLdIdentifiers({
|
|
200
|
+
gtin: '0012345678905',
|
|
201
|
+
mpn: 'MPN-1',
|
|
202
|
+
releaseDate: '1774224000000',
|
|
203
|
+
})
|
|
204
|
+
).toEqual({
|
|
205
|
+
gtin: '0012345678905',
|
|
206
|
+
mpn: 'MPN-1',
|
|
207
|
+
releaseDate: '2026-03-23',
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('omits every identifier that is empty', () => {
|
|
212
|
+
expect(
|
|
213
|
+
toProductJsonLdIdentifiers({ gtin: '', mpn: '', releaseDate: '' })
|
|
214
|
+
).toEqual({})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('omits a releaseDate that cannot be parsed', () => {
|
|
218
|
+
expect(
|
|
219
|
+
toProductJsonLdIdentifiers({
|
|
220
|
+
gtin: '',
|
|
221
|
+
mpn: '',
|
|
222
|
+
releaseDate: 'not-a-date',
|
|
223
|
+
})
|
|
224
|
+
).toEqual({})
|
|
225
|
+
})
|
|
226
|
+
})
|