Skip to content
311 changes: 159 additions & 152 deletions packages/react-components/specs/orders/place-order.spec.tsx

Large diffs are not rendered by default.

420 changes: 420 additions & 0 deletions packages/react-components/specs/orders/terms-acceptance.spec.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// The Drop-in was translated correctly but the Klarna page it redirects to came up in Italian.
// `locale` in the Core configuration is client-side only — it picks the Drop-in's translation
// bundle and never reaches Adyen. The language Adyen uses for the hosted pages it renders
// itself comes from the shopper locale in the payment request — `shopper_locale` in Commerce
// Layer's attributes — which was not being sent.
import { act, render } from "@testing-library/react"
import { AdyenPayment } from "#components/payment_source/AdyenPayment"
import CommerceLayerContext from "#context/CommerceLayerContext"
import CustomerContext from "#context/CustomerContext"
import OrderContext, { defaultOrderContext } from "#context/OrderContext"
import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext"
import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext"

const adyen = vi.hoisted(() => ({
// biome-ignore lint/suspicious/noExplicitAny: test cast
captured: { options: null as any },
}))

vi.mock("@adyen/adyen-web/auto", () => ({
// biome-ignore lint/suspicious/noExplicitAny: test cast
AdyenCheckout: vi.fn(async (options: any) => {
adyen.captured.options = options
return { update: vi.fn() }
}),
Dropin: class FakeDropin {
mount(): this {
return this
}
submit(): void {}
remove(): void {}
unmount(): this {
return this
}
handleAction(): void {}
},
}))

vi.mock("#utils/getPublicIp", () => ({
getPublicIP: vi.fn(async () => "127.0.0.1"),
}))

const PAYMENT_SOURCE = {
id: "ps-1",
type: "adyen_payments",
payment_methods: {
paymentMethods: [{ type: "scheme" }, { type: "klarna_account" }],
},
}

/**
* Mounts the component for an order in `languageCode`, submits a card, and returns the
* `payment_request_data` that went to the API.
*/
async function submitAndCapturePaymentRequest({
languageCode,
shopperLocaleConfig,
}: {
languageCode?: string
shopperLocaleConfig?: string
// biome-ignore lint/suspicious/noExplicitAny: test cast
}): Promise<{ paymentRequestData: any; dropInLocale: string }> {
const setPaymentSource = vi.fn(async () => ({
...PAYMENT_SOURCE,
payment_response: {},
}))
// biome-ignore lint/suspicious/noExplicitAny: test cast
const order: any = {
id: "order-1",
status: "pending",
payment_status: "unpaid",
currency_code: "EUR",
country_code: "IT",
language_code: languageCode,
total_amount_with_taxes_cents: 1000,
line_items: [],
}

await act(async () => {
render(
<CommerceLayerContext.Provider value={{ accessToken: "test-token" }}>
<OrderContext.Provider
value={{
...defaultOrderContext,
orderId: order.id,
order,
updateOrder: vi.fn(),
getOrderByFields: vi.fn().mockResolvedValue({
status: "pending",
payment_status: "unpaid",
}),
}}
>
<CustomerContext.Provider value={{}}>
<PlaceOrderContext.Provider value={defaultPlaceOrderContext}>
<PaymentMethodContext.Provider
value={
{
...defaultPaymentMethodContext,
_isProvided: true as const,
paymentSource: PAYMENT_SOURCE,
currentPaymentMethodType: "scheme",
setPaymentSource,
setPaymentMethodErrors: vi.fn(),
setPaymentRef: vi.fn(),
errors: [],
// biome-ignore lint/suspicious/noExplicitAny: test cast
} as any
}
>
<AdyenPayment
clientKey="test_CLIENTKEY"
config={shopperLocaleConfig ? { shopperLocale: shopperLocaleConfig } : {}}
/>
</PaymentMethodContext.Provider>
</PlaceOrderContext.Provider>
</CustomerContext.Provider>
</OrderContext.Provider>
</CommerceLayerContext.Provider>
)
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})

await act(async () => {
adyen.captured.options.onSubmit(
{ data: { paymentMethod: { type: "scheme" } }, isValid: true },
{ mount: vi.fn() },
{ resolve: vi.fn(), reject: vi.fn() }
)
await new Promise((resolve) => setTimeout(resolve, 0))
})

const call = setPaymentSource.mock.calls
// biome-ignore lint/suspicious/noExplicitAny: test cast
.map(([args]: any[]) => args)
// biome-ignore lint/suspicious/noExplicitAny: test cast
.find((args: any) => args?.attributes?.payment_request_data != null)
return {
paymentRequestData: call?.attributes?.payment_request_data,
dropInLocale: adyen.captured.options.locale,
}
}

describe("AdyenPayment shopperLocale", () => {
beforeEach(() => {
vi.clearAllMocks()
adyen.captured.options = null
})

it("sends shopper_locale derived from the order language", async () => {
const { paymentRequestData, dropInLocale } = await submitAndCapturePaymentRequest({
languageCode: "en",
})

// The Drop-in keeps the bare language it always used — this is only about what Adyen gets.
expect(dropInLocale).toBe("en")
expect(paymentRequestData.shopper_locale).toBe("en-US")
})

it("does not take the language from the country code", async () => {
// The bug: an English order in an Italian market rendered a Klarna page in Italian, because
// nothing carried the language and Adyen fell back to the country.
const { paymentRequestData } = await submitAndCapturePaymentRequest({
languageCode: "en",
})

expect(paymentRequestData.shopper_locale).not.toContain("it")
})

it("lets the config override the derived value", async () => {
const { paymentRequestData } = await submitAndCapturePaymentRequest({
languageCode: "en",
shopperLocaleConfig: "en-GB",
})

expect(paymentRequestData.shopper_locale).toBe("en-GB")
})

it("moves the Drop-in with it, so the two locales cannot disagree", async () => {
const { paymentRequestData, dropInLocale } = await submitAndCapturePaymentRequest({
languageCode: "en",
shopperLocaleConfig: "it-IT",
})

// One locale for both surfaces. Without this an integration that sets the option would
// get a Klarna page in Italian behind a Drop-in in English: the same mismatch as the
// original bug, only chosen on purpose.
expect(dropInLocale).toBe("it-IT")
expect(paymentRequestData.shopper_locale).toBe("it-IT")
})

it("omits shopper_locale when the language cannot be expanded", async () => {
const { paymentRequestData } = await submitAndCapturePaymentRequest({
languageCode: "xx",
})

// Preserves Adyen's existing fallback rather than sending something it may reject.
expect(paymentRequestData).not.toHaveProperty("shopper_locale")
})

it("falls back to the locale prop when the order has no language", async () => {
const { paymentRequestData, dropInLocale } = await submitAndCapturePaymentRequest({
languageCode: undefined,
})

// Same source the Drop-in falls back to, so the two stay consistent.
expect(dropInLocale).toBe("en-US")
expect(paymentRequestData.shopper_locale).toBe("en-US")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const adyen = vi.hoisted(() => ({
// The Core configuration the component builds, so tests can invoke the real
// `onSubmit` handler it installs.
// biome-ignore lint/suspicious/noExplicitAny: test cast
captured: { options: null as any },
captured: { options: null as any, dropinOptions: null as any },
}))

vi.mock("@adyen/adyen-web/auto", () => ({
Expand All @@ -35,6 +35,10 @@ vi.mock("@adyen/adyen-web/auto", () => ({
return { update: adyen.coreUpdate }
}),
Dropin: class FakeDropin {
// biome-ignore lint/suspicious/noExplicitAny: test cast
constructor(_core: any, options: any) {
adyen.captured.dropinOptions = options
}
mount(selector: string): this {
adyen.dropinMount(selector)
return this
Expand Down Expand Up @@ -93,6 +97,7 @@ function Providers({
setPaymentSource,
setPaymentMethodErrors = vi.fn(),
setPaymentRef = vi.fn(),
placeOrderButtonRef,
}: {
children: ReactNode
// biome-ignore lint/suspicious/noExplicitAny: test cast
Expand All @@ -111,6 +116,8 @@ function Providers({
setPaymentMethodErrors?: any
// biome-ignore lint/suspicious/noExplicitAny: test cast
setPaymentRef?: any
// biome-ignore lint/suspicious/noExplicitAny: test cast
placeOrderButtonRef?: any
}) {
// biome-ignore lint/suspicious/noExplicitAny: test cast
const paymentMethodCtx: any = {
Expand All @@ -136,7 +143,7 @@ function Providers({
>
<CustomerContext.Provider value={{}}>
<PlaceOrderContext.Provider
value={{ ...defaultPlaceOrderContext, status: placeOrderStatus }}
value={{ ...defaultPlaceOrderContext, status: placeOrderStatus, placeOrderButtonRef }}
>
<PaymentMethodContext.Provider value={paymentMethodCtx}>
{children}
Expand Down Expand Up @@ -848,3 +855,65 @@ describe("AdyenPayment when the payment source is recreated mid-flight", () => {
expect(new Set(ids)).toEqual(new Set(["ps-recreated"]))
})
})

// A Drop-in method that needs no input — Klarna, PayPal — reports `isValid` the moment it is
// selected. `onSelect` used to answer that by writing `placeOrderButtonRef.current.disabled =
// false` straight onto the DOM node, which skips `isPermitted` entirely: the button went live
// with privacy & terms still unaccepted. React never repaired it either, because its own
// `disabled` prop had not changed — the fiber said `true` while the DOM said `false`.
// Selecting a method may only arm the submit wiring; enabling the button is the button's call.
describe("AdyenPayment does not enable the place-order button behind React's back", () => {
beforeEach(() => {
vi.clearAllMocks()
adyen.captured.options = null
adyen.captured.dropinOptions = null
})

it("leaves the button disabled when a no-input method reports itself valid on selection", async () => {
// Stands in for the button `PlaceOrderButton` registers: React rendered it disabled
// because the terms are unaccepted.
const button = document.createElement("button")
button.disabled = true
const setPaymentRef = vi.fn()

render(
<Providers placeOrderButtonRef={{ current: button }} setPaymentRef={setPaymentRef}>
<AdyenPayment clientKey="test_CLIENTKEY" />
</Providers>
)
await flush()

expect(adyen.captured.dropinOptions?.onSelect).toBeTypeOf("function")

await act(async () => {
adyen.captured.dropinOptions.onSelect({ _id: "klarna-0", isValid: true })
await new Promise((resolve) => setTimeout(resolve, 0))
})

expect(button.disabled).toBe(true)
// The supported channel is still used, so the button can enable itself once
// `isPermitted` allows it.
expect(setPaymentRef).toHaveBeenCalled()
})

it("leaves the button disabled when the Drop-in reports a valid change", async () => {
const button = document.createElement("button")
button.disabled = true
const setPaymentRef = vi.fn()

render(
<Providers placeOrderButtonRef={{ current: button }} setPaymentRef={setPaymentRef}>
<AdyenPayment clientKey="test_CLIENTKEY" />
</Providers>
)
await flush()

await act(async () => {
adyen.captured.options.onChange({ isValid: true })
await new Promise((resolve) => setTimeout(resolve, 0))
})

expect(button.disabled).toBe(true)
expect(setPaymentRef).toHaveBeenCalled()
})
})
36 changes: 36 additions & 0 deletions packages/react-components/specs/utils/adyenShopperLocale.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Adyen's `locale` (Drop-in translations, client-side) and `shopperLocale` (payment request,
// used for the hosted pages Adyen renders itself) are different things. Commerce Layer's
// `order.language_code` is a bare ISO 639-1 code, so it has to be expanded for the latter.
import { getAdyenShopperLocale } from "#utils/adyenShopperLocale"

describe("getAdyenShopperLocale", () => {
it("expands a bare language whose region mirrors it", () => {
expect(getAdyenShopperLocale("it")).toBe("it-IT")
expect(getAdyenShopperLocale("de")).toBe("de-DE")
expect(getAdyenShopperLocale("fr")).toBe("fr-FR")
expect(getAdyenShopperLocale("nl")).toBe("nl-NL")
})

it("expands a bare language whose region differs", () => {
// The reported case: an English Drop-in handing over to a Klarna page. `en-EN` is not a
// locale, so it needs the explicit mapping.
expect(getAdyenShopperLocale("en")).toBe("en-US")
expect(getAdyenShopperLocale("sv")).toBe("sv-SE")
expect(getAdyenShopperLocale("cs")).toBe("cs-CZ")
expect(getAdyenShopperLocale("ja")).toBe("ja-JP")
})

it("normalises a locale that already carries a region", () => {
expect(getAdyenShopperLocale("en-US")).toBe("en-US")
expect(getAdyenShopperLocale("en_US")).toBe("en-US")
expect(getAdyenShopperLocale("PT_br")).toBe("pt-BR")
})

it("returns undefined rather than guessing", () => {
// Preserves Adyen's own fallback instead of sending a locale it may reject.
expect(getAdyenShopperLocale(undefined)).toBeUndefined()
expect(getAdyenShopperLocale(null)).toBeUndefined()
expect(getAdyenShopperLocale("")).toBeUndefined()
expect(getAdyenShopperLocale("xx")).toBeUndefined()
})
})
Loading
Loading