@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.
@@ -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, isSessionReady, ...session } = useSession()
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, isSessionReady, ...session } = useSession()
48
+ const { isValidating, ...session } = useSession()
49
49
  const { state: searchState, setState: setSearchState } = useSearch()
50
50
  const {
51
51
  loading: loadingRegion,
@@ -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 { toProductJsonLdOffer } from 'src/utils/productJsonLd'
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
- // Spread conditionally so an unregistered identifier is omitted rather
299
- // than published as "": next-seo passes these through untouched.
300
- {...(product.gtin && { gtin: product.gtin })}
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
  },
@@ -13,7 +13,11 @@ import type {
13
13
 
14
14
  import storeConfig from '../../../discovery.config'
15
15
  import { request } from '../graphql/request'
16
- import { hasValidatedSessionStore, sessionStore } from '../session'
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: sessionStore.read(),
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,
@@ -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: sessionWithoutExtras,
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
- if (deepEqual(val, defaultStore.read()) === true) return
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(val)
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
- if (deepEqual(val, defaultStore.read()) === true) return
225
+ const session = toSessionInput(val)
226
+ if (deepEqual(session, defaultStore.read()) === true) return
228
227
 
229
- defaultStore.set(val)
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
- ...session,
282
- channel,
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
- [isValidating, session, channel, isSessionReady, hasValidated]
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 ServerOffers = ServerProductQueryQuery['product']['offers']
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 { toProductJsonLdOffer } from '../../src/utils/productJsonLd'
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
+ })