Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/api/src/platforms/vtex/clients/commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,21 +435,35 @@
id,
refreshOutdatedData = true,
channel = ctx.storage.channel,
preserveSalesChannel = false,
}: {
id?: string
refreshOutdatedData?: boolean
channel?: Required<Channel>
/**
* When true, omit the `sc` query param so Checkout keeps the cart's
* current sales channel. Use this when the session SC may be stale
* relative to an external flow (e.g. Quick Order) that already
* advanced the orderForm.
*/
preserveSalesChannel?: boolean
}): Promise<OrderForm> => {
const { salesChannel } = channel
const headers: HeadersInit = withCookie({
'content-type': 'application/json',
'X-FORWARDED-HOST': forwardedHost,
})
const params = new URLSearchParams({ sc: salesChannel })
const params = new URLSearchParams()
// New carts (no id) always need an explicit SC. Existing carts may
// omit it so Checkout does not recalculate under a stale session SC.
if (!preserveSalesChannel || !id) {
params.set('sc', salesChannel)
}
if (id) {
params.set('refreshOutdatedData', refreshOutdatedData.toString())
}
const url = `${base}/api/checkout/pub/orderForm${id ? `/${id}` : ''}?${params.toString()}`
const qs = params.toString()
const url = `${base}/api/checkout/pub/orderForm${id ? `/${id}` : ''}${qs ? `?${qs}` : ''}`

Check warning on line 466 in packages/api/src/platforms/vtex/clients/commerce/index.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/clients/commerce/index.ts#L466

Refactor this code to not use nested template literals.

Check warning on line 466 in packages/api/src/platforms/vtex/clients/commerce/index.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/clients/commerce/index.ts#L466

Refactor this code to not use nested template literals.

return fetchAPI(
url,
Expand Down
45 changes: 42 additions & 3 deletions packages/api/src/platforms/vtex/resolvers/validateCart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import deepEquals from 'fast-deep-equal'

import { parse } from 'cookie'
import {
channelAfterExternalOrderFormSync,
shouldRefetchOrderFormWithSessionSalesChannel,
} from '../utils/cartSalesChannel'
import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
import { md5 } from '../utils/md5'
import {
Expand Down Expand Up @@ -353,7 +357,7 @@
_: unknown,
{ cart: { order }, session }: MutationValidateCartArgs,
ctx: GraphqlContext
) => {

Check failure on line 360 in packages/api/src/platforms/vtex/resolvers/validateCart.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/resolvers/validateCart.ts#L360

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.
const orderFormIdFromCookie = getCookieCheckoutOrderNumber(
ctx.headers.cookie,
'checkout.vtex.com'
Expand All @@ -374,10 +378,17 @@
mutateLocaleContext(ctx, locale)
}

// Step1: Get OrderForm from VTEX Commerce
const orderForm = await commerce.checkout.orderForm({
id: orderFormIdFromCookie || undefined,
// Step1: Get OrderForm from VTEX Commerce.
// For existing carts (`orderFormId` present), omit `sc` on the first GET so
// Checkout keeps the orderForm's current sales channel. Passing a stale
// session SC (e.g. after Quick Order) would recalculate the cart and drop
// items only available in the orderForm's trade policy. New carts still
// send `sc` from the session (see commerce.checkout.orderForm).
const orderFormId = orderFormIdFromCookie || undefined
let orderForm = await commerce.checkout.orderForm({
id: orderFormId,
channel: ctx.storage.channel,
preserveSalesChannel: Boolean(orderFormId),
})
const orderNumber = orderForm.orderFormId

Expand All @@ -398,6 +409,17 @@
const isStale = isOrderFormStale(orderForm, sessionJwt)

if (isStale) {
// Adopt the orderForm SC so subsequent checkout calls (etag, etc.) stay
// on the trade policy that actually owns the items.
const adoptedChannel = channelAfterExternalOrderFormSync(
ctx.storage.channel,
orderForm.salesChannel,
isStale
)
if (adoptedChannel) {
mutateChannelContext(ctx, adoptedChannel)
}

const newOrderForm = await setOrderFormEtag(
orderForm,
commerce,
Expand All @@ -408,6 +430,23 @@
}
}

// Session owns the channel when the cart is not externally stale. If the
// user switched sales channel in-session (e.g. locale/binding), re-fetch
// with the session SC so Checkout recalculates under the new trade policy.
if (
shouldRefetchOrderFormWithSessionSalesChannel(
ctx.storage.channel.salesChannel,
orderForm.salesChannel,
isStale
)
) {
orderForm = await commerce.checkout.orderForm({
id: orderForm.orderFormId,
channel: ctx.storage.channel,
preserveSalesChannel: false,
})
}

// Step2: Process items from both browser and checkout so they have the same shape
const browserItemsById = groupById(acceptedOffer)
const originItemsById = groupById(orderForm.items.map(orderFormItemToOffer))
Expand Down
55 changes: 55 additions & 0 deletions packages/api/src/platforms/vtex/utils/cartSalesChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Channel } from './channel'
import ChannelMarshal from './channel'

/**
* When another system changed the orderForm (stale cartEtag), the orderForm's
* sales channel is authoritative. Returns a channel string to apply via
* `mutateChannelContext`, or `null` when no adoption is needed.
*/
export function channelAfterExternalOrderFormSync(
currentChannel: Required<Channel>,
orderFormSalesChannel: string | null | undefined,
isOrderFormStale: boolean
): string | null {
if (!isOrderFormStale) {
return null
}

if (orderFormSalesChannel == null || orderFormSalesChannel === '') {
return null
}

if (
String(orderFormSalesChannel) === String(currentChannel.salesChannel ?? '')
) {
return null
}

return ChannelMarshal.stringify({
...currentChannel,
salesChannel: String(orderFormSalesChannel),
hasOnlyDefaultSalesChannel: false,
})
}

/**
* After a non-stale validation, the session owns the channel. If session SC
* differs from the orderForm's SC (e.g. locale/binding switch), the cart must
* be re-fetched with the session SC so Checkout recalculates under the new
* trade policy.
*/
export function shouldRefetchOrderFormWithSessionSalesChannel(
sessionSalesChannel: string | undefined,
orderFormSalesChannel: string | null | undefined,
isOrderFormStale: boolean
): boolean {
if (isOrderFormStale) {
return false
}

if (!sessionSalesChannel || !orderFormSalesChannel) {
return false
}

return String(sessionSalesChannel) !== String(orderFormSalesChannel)
}
6 changes: 3 additions & 3 deletions packages/api/test/mocks/ValidateCartMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const InvalidCart = {
// Valid Cart

export const checkoutOrderFormValidFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand All @@ -289,7 +289,7 @@ export const checkoutOrderFormCustomDataValidFetch = {
// "Invalid" Cart

export const checkoutOrderFormInvalidFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand Down Expand Up @@ -515,7 +515,7 @@ export const createProductFetchResultForSku = (

// Stale Cart
export const checkoutOrderFormStaleFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand Down
55 changes: 55 additions & 0 deletions packages/api/test/unit/platforms/vtex/clients/commerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,61 @@ describe('VTEX Commerce', () => {
expect(fetchAPIMocked).not.toHaveBeenCalled()
})
})

describe('orderForm', () => {
it('includes sc query param by default', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-1',
salesChannel: '1',
items: [],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({ id: 'of-1' })

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm/of-1?')
expect(url).toContain('sc=1')
expect(url).toContain('refreshOutdatedData=true')
})

it('omits sc when preserveSalesChannel is true for an existing cart', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-1',
salesChannel: '4',
items: [{ id: 'sku-1' }],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({
id: 'of-1',
preserveSalesChannel: true,
})

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm/of-1?')
expect(url).not.toContain('sc=')
expect(url).toContain('refreshOutdatedData=true')
})

it('still sends sc when preserveSalesChannel is true but creating a new cart', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-new',
salesChannel: '1',
items: [],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({
preserveSalesChannel: true,
})

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm?')
expect(url).toContain('sc=1')
expect(url).not.toContain('refreshOutdatedData')
})
})
})

describe('Order Entry', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import {
channelAfterExternalOrderFormSync,
shouldRefetchOrderFormWithSessionSalesChannel,
} from '../../../../../src/platforms/vtex/utils/cartSalesChannel'
import type { Channel } from '../../../../../src/platforms/vtex/utils/channel'

const baseChannel = (salesChannel: string): Required<Channel> => ({
salesChannel,
regionId: '',
seller: '',
hasOnlyDefaultSalesChannel: true,
})

describe('channelAfterExternalOrderFormSync', () => {
it('returns null when the orderForm is not stale', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), '4', false)
).toBeNull()
})

it('returns null when orderForm SC matches session SC', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('4'), '4', true)
).toBeNull()
})

it('returns null when orderForm SC is missing', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), null, true)
).toBeNull()
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), '', true)
).toBeNull()
})

it('adopts orderForm SC when stale and divergent (Quick Order case)', () => {
const result = channelAfterExternalOrderFormSync(
baseChannel('1'),
'4',
true
)

expect(result).not.toBeNull()
expect(JSON.parse(result as string)).toMatchObject({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
salesChannel: '4',
hasOnlyDefaultSalesChannel: false,
})
})
})

describe('shouldRefetchOrderFormWithSessionSalesChannel', () => {
it('does not refetch when the orderForm is externally stale', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('1', '4', true)).toBe(
false
)
})

it('does not refetch when SCs already match', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('1', '1', false)).toBe(
false
)
})

it('refetches when session SC diverges and cart is not stale (locale switch)', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('2', '1', false)).toBe(
true
)
})

it('does not refetch when either SC is missing', () => {
expect(
shouldRefetchOrderFormWithSessionSalesChannel(undefined, '1', false)
).toBe(false)
expect(
shouldRefetchOrderFormWithSessionSalesChannel('1', null, false)
).toBe(false)
})
})
42 changes: 41 additions & 1 deletion packages/core/src/sdk/cart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {

import storeConfig from '../../../discovery.config'
import { request } from '../graphql/request'
import { sessionStore } from '../session'
import { hasValidatedSessionStore, sessionStore } from '../session'
import { createValidationStore, useStore } from '../useStore'

export interface CartItem
Expand All @@ -28,6 +28,44 @@ export interface Cart extends SDKCart<CartItem> {
shouldSplitItem?: boolean
}

/** Max time to wait for the first validateSession before validating the cart. */
const SESSION_VALIDATION_WAIT_MS = 3_000

/**
* Defers cart validation until session has been validated at least once, so
* `session.channel` (sales channel) is less likely to be stale relative to
* Session Manager / external flows like Quick Order.
*/
const waitForSessionValidated = async (): Promise<void> => {
if (hasValidatedSessionStore.read()) {
return
}

await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
unsubscribe()
resolve()
}, SESSION_VALIDATION_WAIT_MS)

const unsubscribe = hasValidatedSessionStore.subscribe((validated) => {
if (!validated) {
return
}

clearTimeout(timeoutId)
unsubscribe()
resolve()
})

// Race: validation may have finished between read() and subscribe()
if (hasValidatedSessionStore.read()) {
clearTimeout(timeoutId)
unsubscribe()
resolve()
}
})
}

export const ValidateCartMutation = gql(`
mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {
validateCart(cart: $cart, session: $session) {
Expand Down Expand Up @@ -114,6 +152,8 @@ const getItemId = (item: Pick<CartItem, 'itemOffered' | 'seller' | 'price'>) =>
.join('::')

const validateCart = async (cart: Cart): Promise<Cart | null> => {
await waitForSessionValidated()

const { validateCart: validated = null } = await request<
ValidateCartMutationMutation,
ValidateCartMutationMutationVariables
Expand Down
Loading