From 5439214f579a6eeb255b670c01960fcf66b93f98 Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 17:27:40 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(line-items):?= =?UTF-8?q?=20deprecate=20LineItemsContainer,=20add=20LineItems=20standalo?= =?UTF-8?q?ne=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add core functions: getLineItems, updateLineItem, deleteLineItem - Add useLineItems hook (SWR-based) to @commercelayer/hooks - Add LineItems standalone component (no OrderContext required) - supports types prop for item_type filtering - supports onUpdate/onDelete callbacks - exposes reload() via LineItemContext - Deprecate LineItemsContainer in favour of LineItems - Fix LineItemsCount contextComponentName warning (LineItemsContainer → LineItems) - Add reload to LineItemContext Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/index.ts | 1 + .../core/src/line_items/deleteLineItem.ts | 22 ++++ packages/core/src/line_items/getLineItems.ts | 30 +++++ packages/core/src/line_items/index.ts | 3 + .../core/src/line_items/updateLineItem.ts | 33 ++++++ packages/hooks/src/index.ts | 1 + packages/hooks/src/line_items/useLineItems.ts | 108 ++++++++++++++++++ .../src/components/line_items/LineItems.tsx | 87 ++++++++++++++ .../line_items/LineItemsContainer.tsx | 4 + .../components/line_items/LineItemsCount.tsx | 2 +- .../src/context/LineItemContext.ts | 1 + packages/react-components/src/index.ts | 1 + 12 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/line_items/deleteLineItem.ts create mode 100644 packages/core/src/line_items/getLineItems.ts create mode 100644 packages/core/src/line_items/index.ts create mode 100644 packages/core/src/line_items/updateLineItem.ts create mode 100644 packages/hooks/src/line_items/useLineItems.ts create mode 100644 packages/react-components/src/components/line_items/LineItems.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b929e07..039b919f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./auth" export * from "./availability" export * from "./createBatchStore" +export * from "./line_items" export * from "./orders" export * from "./prices" export * from "./sdk" diff --git a/packages/core/src/line_items/deleteLineItem.ts b/packages/core/src/line_items/deleteLineItem.ts new file mode 100644 index 00000000..87ba8a43 --- /dev/null +++ b/packages/core/src/line_items/deleteLineItem.ts @@ -0,0 +1,22 @@ +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface DeleteLineItemParams extends Pick { + lineItemId: string +} + +/** + * Delete a line item by ID. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} lineItemId - The ID of the line item to delete. + * @returns {Promise} + */ +export async function deleteLineItem({ + accessToken, + interceptors, + lineItemId, +}: DeleteLineItemParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + await sdk.line_items.delete(lineItemId) +} diff --git a/packages/core/src/line_items/getLineItems.ts b/packages/core/src/line_items/getLineItems.ts new file mode 100644 index 00000000..abd03f98 --- /dev/null +++ b/packages/core/src/line_items/getLineItems.ts @@ -0,0 +1,30 @@ +import type { LineItem } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface GetLineItemsParams extends Pick { + orderId: string +} + +/** + * Retrieve all line items for a given order. + * + * Fetches the order with the necessary includes to fully populate + * line items, their options and the associated item resource. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} orderId - The ID of the order whose line items to retrieve. + * @returns {Promise} - The list of line item resources. + */ +export async function getLineItems({ + accessToken, + interceptors, + orderId, +}: GetLineItemsParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const order = await sdk.orders.retrieve(orderId, { + include: ["line_items", "line_items.line_item_options.sku_option", "line_items.item"], + fields: { orders: ["line_items"] }, + }) + return order.line_items ?? [] +} diff --git a/packages/core/src/line_items/index.ts b/packages/core/src/line_items/index.ts new file mode 100644 index 00000000..2e17867d --- /dev/null +++ b/packages/core/src/line_items/index.ts @@ -0,0 +1,3 @@ +export { getLineItems } from "./getLineItems" +export { updateLineItem } from "./updateLineItem" +export { deleteLineItem } from "./deleteLineItem" diff --git a/packages/core/src/line_items/updateLineItem.ts b/packages/core/src/line_items/updateLineItem.ts new file mode 100644 index 00000000..953fd785 --- /dev/null +++ b/packages/core/src/line_items/updateLineItem.ts @@ -0,0 +1,33 @@ +import type { LineItem } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface UpdateLineItemParams extends Pick { + lineItemId: string + quantity?: number + hasExternalPrice?: boolean +} + +/** + * Update a line item's quantity and/or external price flag. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} lineItemId - The ID of the line item to update. + * @param {number} quantity - The new quantity for the line item. + * @param {boolean} hasExternalPrice - Whether to use an external price for the line item. + * @returns {Promise} - The updated line item resource. + */ +export async function updateLineItem({ + accessToken, + interceptors, + lineItemId, + quantity, + hasExternalPrice, +}: UpdateLineItemParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + return await sdk.line_items.update({ + id: lineItemId, + quantity, + _external_price: hasExternalPrice, + }) +} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 55376b2e..f73bce3c 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,5 +1,6 @@ export type { InterceptorManager } from "@commercelayer/core" export { useAvailability } from "./availability/useAvailability" +export { useLineItems } from "./line_items/useLineItems" export { useOrder } from "./orders/useOrder" export { usePrices } from "./prices/usePrices" export { useSkuList } from "./sku_lists/useSkuList" diff --git a/packages/hooks/src/line_items/useLineItems.ts b/packages/hooks/src/line_items/useLineItems.ts new file mode 100644 index 00000000..a59a676f --- /dev/null +++ b/packages/hooks/src/line_items/useLineItems.ts @@ -0,0 +1,108 @@ +import { + deleteLineItem as coreDeleteLineItem, + getLineItems, + type InterceptorManager, + updateLineItem as coreUpdateLineItem, +} from "@commercelayer/core" +import type { LineItem } from "@commercelayer/sdk" +import { useCallback } from "react" +import useSWR, { type KeyedMutator } from "swr" + +interface UseLineItemsParams { + accessToken: string + orderId?: string | null + interceptors?: InterceptorManager +} + +interface UseLineItemsReturn { + lineItems: LineItem[] + isLoading: boolean + isValidating: boolean + error: string | null + updateLineItem: ( + lineItemId: string, + quantity?: number, + hasExternalPrice?: boolean + ) => Promise + deleteLineItem: (lineItemId: string) => Promise + reload: () => Promise + mutate: KeyedMutator +} + +/** + * React hook for fetching and managing line items for a Commerce Layer order. + * + * Uses SWR for data fetching and caching. Provides `updateLineItem` and + * `deleteLineItem` helpers that automatically refresh the cached data. + * + * @param accessToken - Commerce Layer API access token. + * @param orderId - ID of the order whose line items to fetch. Pass `null` or omit to skip fetching. + * @param interceptors - Optional SDK interceptors. + * + * @example + * ```tsx + * const { lineItems, isLoading, updateLineItem, deleteLineItem } = useLineItems({ + * accessToken, + * orderId: 'xYzAbCdE', + * }) + * ``` + */ +export function useLineItems({ + accessToken, + orderId, + interceptors, +}: UseLineItemsParams): UseLineItemsReturn { + const swrKey = + accessToken && orderId ? ["line_items", "get", accessToken, orderId] : null + + const { data, error, isLoading, isValidating, mutate } = useSWR( + swrKey, + async (): Promise => { + if (!orderId) throw new Error("orderId is required") + return getLineItems({ accessToken, interceptors, orderId }) + }, + { revalidateOnFocus: false, revalidateOnReconnect: false } + ) + + const updateLineItem = useCallback( + async ( + lineItemId: string, + quantity?: number, + hasExternalPrice?: boolean + ): Promise => { + const updated = await coreUpdateLineItem({ + accessToken, + interceptors, + lineItemId, + quantity, + hasExternalPrice, + }) + await mutate() + return updated + }, + [accessToken, interceptors, mutate] + ) + + const deleteLineItem = useCallback( + async (lineItemId: string): Promise => { + await coreDeleteLineItem({ accessToken, interceptors, lineItemId }) + await mutate() + }, + [accessToken, interceptors, mutate] + ) + + const reload = useCallback(async (): Promise => { + return await mutate() + }, [mutate]) + + return { + lineItems: data ?? [], + isLoading, + isValidating, + error: error?.message ?? null, + updateLineItem, + deleteLineItem, + reload, + mutate, + } +} diff --git a/packages/react-components/src/components/line_items/LineItems.tsx b/packages/react-components/src/components/line_items/LineItems.tsx new file mode 100644 index 00000000..c18fdc08 --- /dev/null +++ b/packages/react-components/src/components/line_items/LineItems.tsx @@ -0,0 +1,87 @@ +import { useLineItems } from "@commercelayer/hooks" +import { type JSX } from "react" +import LineItemContext, { type LineItemContextValue } from "#context/LineItemContext" +import type { BaseError } from "#typings/errors" +import type { DefaultChildrenType } from "#typings/globals" +import type { TLineItem } from "./LineItem" + +interface Props { + children: DefaultChildrenType + /** + * Commerce Layer API access token. + */ + accessToken: string + /** + * ID of the order whose line items to display. + */ + orderId: string + /** + * Filter line items by item type. When provided, only matching types + * are put into context (affects LineItem, LineItemsCount, LineItemsEmpty). + */ + types?: TLineItem[] + /** + * Element to display while line items are loading. + */ + loader?: JSX.Element + /** + * Called after a line item has been successfully updated. + */ + onUpdate?: (lineItemId: string) => void + /** + * Called after a line item has been successfully deleted. + */ + onDelete?: (lineItemId: string) => void +} + +export function LineItems({ + children, + accessToken, + orderId, + types, + loader = <>Loading..., + onUpdate, + onDelete, +}: Props): JSX.Element { + const { + lineItems: allLineItems, + isLoading, + error, + updateLineItem: hookUpdate, + deleteLineItem: hookDelete, + reload: hookReload, + } = useLineItems({ accessToken, orderId }) + + const lineItems = + types != null ? allLineItems.filter((li) => types.includes(li.item_type as TLineItem)) : allLineItems + + const errors: BaseError[] = error + ? [{ code: "INTERNAL_SERVER_ERROR", message: error, resource: "line_items" }] + : [] + + const contextValue: LineItemContextValue = { + lineItems, + loading: isLoading, + errors, + loader, + updateLineItem: async (lineItemId, quantity = 1, hasExternalPrice) => { + await hookUpdate(lineItemId, quantity, hasExternalPrice) + onUpdate?.(lineItemId) + }, + deleteLineItem: async (lineItemId) => { + await hookDelete(lineItemId) + onDelete?.(lineItemId) + }, + reload: async () => { + await hookReload() + }, + } + + return ( + + {isLoading ? loader : children} + + ) +} + +export default LineItems diff --git a/packages/react-components/src/components/line_items/LineItemsContainer.tsx b/packages/react-components/src/components/line_items/LineItemsContainer.tsx index 5454796f..667d1be5 100644 --- a/packages/react-components/src/components/line_items/LineItemsContainer.tsx +++ b/packages/react-components/src/components/line_items/LineItemsContainer.tsx @@ -15,6 +15,10 @@ interface Props { loader?: JSX.Element } +/** + * @deprecated Use `` instead. + * `LineItemsContainer` requires an `OrderContext` parent and will be removed in a future major version. + */ export function LineItemsContainer(props: Props): JSX.Element { const { children, loader = "Loading..." } = props const { order, addResourceToInclude, include, orderId, getOrder, includeLoaded } = diff --git a/packages/react-components/src/components/line_items/LineItemsCount.tsx b/packages/react-components/src/components/line_items/LineItemsCount.tsx index 074b686e..0922fd7d 100644 --- a/packages/react-components/src/components/line_items/LineItemsCount.tsx +++ b/packages/react-components/src/components/line_items/LineItemsCount.tsx @@ -18,7 +18,7 @@ export function LineItemsCount(props: Props): JSX.Element { const { children, typeAccepted, ...p } = props const { lineItems } = useCustomContext({ context: LineItemContext, - contextComponentName: "LineItemsContainer", + contextComponentName: "LineItems", currentComponentName: "LineItemsCount", key: "lineItems", }) diff --git a/packages/react-components/src/context/LineItemContext.ts b/packages/react-components/src/context/LineItemContext.ts index 81509493..0e8c5477 100644 --- a/packages/react-components/src/context/LineItemContext.ts +++ b/packages/react-components/src/context/LineItemContext.ts @@ -4,6 +4,7 @@ import type { LineItem } from "@commercelayer/sdk" export interface LineItemContextValue extends LineItemState { lineItems?: LineItem[] | null + reload?: () => Promise } const initial: LineItemContextValue = {} diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 806e0579..09fc744a 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -41,6 +41,7 @@ export * from "#components/line_items/LineItemOption" export * from "#components/line_items/LineItemOptions" export * from "#components/line_items/LineItemQuantity" export * from "#components/line_items/LineItemRemoveLink" +export * from "#components/line_items/LineItems" export * from "#components/line_items/LineItemsContainer" export * from "#components/line_items/LineItemsCount" export * from "#components/line_items/LineItemsEmpty" From 8ae473cf71dbdbf7ef33492819aa11a39b422a3e Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 17:35:00 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=85=20test(line-items):=20add=20tests?= =?UTF-8?q?=20for=20core=20functions,=20useLineItems=20hook=20and=20LineIt?= =?UTF-8?q?ems=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/line_items/deleteLineItem.spec.ts | 83 +++++ .../core/src/line_items/getLineItems.spec.ts | 113 +++++++ .../src/line_items/updateLineItem.spec.ts | 103 +++++++ .../hooks/src/line_items/useLineItems.test.ts | 200 ++++++++++++ .../specs/line_items/LineItems.spec.tsx | 285 ++++++++++++++++++ 5 files changed, 784 insertions(+) create mode 100644 packages/core/src/line_items/deleteLineItem.spec.ts create mode 100644 packages/core/src/line_items/getLineItems.spec.ts create mode 100644 packages/core/src/line_items/updateLineItem.spec.ts create mode 100644 packages/hooks/src/line_items/useLineItems.test.ts create mode 100644 packages/react-components/specs/line_items/LineItems.spec.tsx diff --git a/packages/core/src/line_items/deleteLineItem.spec.ts b/packages/core/src/line_items/deleteLineItem.spec.ts new file mode 100644 index 00000000..aa3b19af --- /dev/null +++ b/packages/core/src/line_items/deleteLineItem.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { deleteLineItem } from "./deleteLineItem.js" + +const { + mockDelete, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockDelete = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockDelete, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + line_items: { delete: mockDelete }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +describe("deleteLineItem", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + mockDelete.mockResolvedValue(undefined) + }) + + test("resolves without a return value", async () => { + const result = await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(result).toBeUndefined() + }) + + test("calls line_items.delete with the correct id", async () => { + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockDelete).toHaveBeenCalledWith("li_1") + }) + + test("forwards request interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/line_items/getLineItems.spec.ts b/packages/core/src/line_items/getLineItems.spec.ts new file mode 100644 index 00000000..9362df81 --- /dev/null +++ b/packages/core/src/line_items/getLineItems.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { getLineItems } from "./getLineItems.js" + +const { + mockRetrieve, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockRetrieve = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockRetrieve, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + orders: { retrieve: mockRetrieve }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2 }, + { id: "li_2", item_type: "gift_cards", quantity: 1 }, +] + +describe("getLineItems", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + }) + + test("returns line_items from the retrieved order", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: MOCK_LINE_ITEMS }) + + const result = await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(result).toEqual(MOCK_LINE_ITEMS) + }) + + test("returns an empty array when order has no line_items", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1" }) + + const result = await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(result).toEqual([]) + }) + + test("calls orders.retrieve with the correct orderId and includes", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + + await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(mockRetrieve).toHaveBeenCalledWith( + "order-1", + expect.objectContaining({ + include: expect.arrayContaining([ + "line_items", + "line_items.line_item_options.sku_option", + "line_items.item", + ]), + fields: { orders: ["line_items"] }, + }) + ) + }) + + test("forwards request interceptors to getSdk", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await getLineItems({ accessToken: "fake-token", orderId: "order-1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await getLineItems({ accessToken: "fake-token", orderId: "order-1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + + await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/line_items/updateLineItem.spec.ts b/packages/core/src/line_items/updateLineItem.spec.ts new file mode 100644 index 00000000..b6243c56 --- /dev/null +++ b/packages/core/src/line_items/updateLineItem.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { updateLineItem } from "./updateLineItem.js" + +const { + mockUpdate, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockUpdate = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockUpdate, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + line_items: { update: mockUpdate }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +const MOCK_LINE_ITEM = { id: "li_1", item_type: "skus", quantity: 3 } + +describe("updateLineItem", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + mockUpdate.mockResolvedValue(MOCK_LINE_ITEM) + }) + + test("returns the updated line item", async () => { + const result = await updateLineItem({ + accessToken: "fake-token", + lineItemId: "li_1", + quantity: 3, + }) + + expect(result).toEqual(MOCK_LINE_ITEM) + }) + + test("calls line_items.update with correct id and quantity", async () => { + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", quantity: 2 }) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "li_1", quantity: 2 }) + ) + }) + + test("passes hasExternalPrice as _external_price", async () => { + await updateLineItem({ + accessToken: "fake-token", + lineItemId: "li_1", + hasExternalPrice: true, + }) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "li_1", _external_price: true }) + ) + }) + + test("forwards request interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/hooks/src/line_items/useLineItems.test.ts b/packages/hooks/src/line_items/useLineItems.test.ts new file mode 100644 index 00000000..720fe01c --- /dev/null +++ b/packages/hooks/src/line_items/useLineItems.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { createElement } from "react" +import { SWRConfig } from "swr" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { InterceptorManager } from "../index" +import { useLineItems } from "./useLineItems" + +const swrWrapper = ({ children }: { children: ReactNode }) => + createElement(SWRConfig, { value: { provider: () => new Map() } }, children) + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2 }, + { id: "li_2", item_type: "gift_cards", quantity: 1 }, +] +const MOCK_UPDATED_LINE_ITEM = { id: "li_1", item_type: "skus", quantity: 3 } + +const mockGetLineItems = vi.fn().mockResolvedValue(MOCK_LINE_ITEMS) +const mockUpdateLineItem = vi.fn().mockResolvedValue(MOCK_UPDATED_LINE_ITEM) +const mockDeleteLineItem = vi.fn().mockResolvedValue(undefined) + +vi.mock("@commercelayer/core", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLineItems: (...args: unknown[]) => mockGetLineItems(...args), + updateLineItem: (...args: unknown[]) => mockUpdateLineItem(...args), + deleteLineItem: (...args: unknown[]) => mockDeleteLineItem(...args), + } +}) + +describe("useLineItems", () => { + const accessToken = "test-token" + const orderId = "order-1" + + beforeEach(() => { + mockGetLineItems.mockClear() + mockUpdateLineItem.mockClear() + mockDeleteLineItem.mockClear() + }) + + it("returns empty array and no loading when orderId is null", () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId: null }), { + wrapper: swrWrapper, + }) + + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("returns empty array and no loading when orderId is undefined", () => { + const { result } = renderHook(() => useLineItems({ accessToken }), { + wrapper: swrWrapper, + }) + + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("fetches and returns line items when accessToken and orderId are provided", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => { + expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + }) + + it("calls getLineItems with correct accessToken and orderId", async () => { + renderHook(() => useLineItems({ accessToken, orderId }), { wrapper: swrWrapper }) + + await waitFor(() => { + expect(mockGetLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, orderId }) + ) + }) + }) + + it("updateLineItem calls core function with correct args and triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + let updated: Awaited> + await act(async () => { + updated = await result.current.updateLineItem("li_1", 3, false) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, lineItemId: "li_1", quantity: 3, hasExternalPrice: false }) + ) + expect(updated!).toEqual(MOCK_UPDATED_LINE_ITEM) + // SWR revalidates — getLineItems is called again + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("deleteLineItem calls core function and triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + await act(async () => { + await result.current.deleteLineItem("li_1") + }) + + expect(mockDeleteLineItem).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, lineItemId: "li_1" }) + ) + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("reload triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + await act(async () => { + await result.current.reload() + }) + + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("exposes error message when getLineItems throws", async () => { + mockGetLineItems.mockRejectedValueOnce(new Error("Unauthorized")) + + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor( + () => { + expect(result.current.error).toBe("Unauthorized") + expect(result.current.lineItems).toEqual([]) + }, + { timeout: 5000 } + ) + }) + + it("passes interceptors to getLineItems", async () => { + const interceptors: InterceptorManager = { + request: { onSuccess: vi.fn((req) => req) }, + } + + renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + wrapper: swrWrapper, + }) + + await waitFor(() => { + expect(mockGetLineItems).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + }) + }) + + it("passes interceptors to updateLineItem", async () => { + const interceptors: InterceptorManager = { + request: { onSuccess: vi.fn((req) => req) }, + } + + const { result } = renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + wrapper: swrWrapper, + }) + + await act(async () => { + await result.current.updateLineItem("li_1", 2) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + }) + + it("passes interceptors to deleteLineItem", async () => { + const interceptors: InterceptorManager = { + request: { onSuccess: vi.fn((req) => req) }, + } + + const { result } = renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + wrapper: swrWrapper, + }) + + await act(async () => { + await result.current.deleteLineItem("li_1") + }) + + expect(mockDeleteLineItem).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + }) +}) diff --git a/packages/react-components/specs/line_items/LineItems.spec.tsx b/packages/react-components/specs/line_items/LineItems.spec.tsx new file mode 100644 index 00000000..cd3e28aa --- /dev/null +++ b/packages/react-components/specs/line_items/LineItems.spec.tsx @@ -0,0 +1,285 @@ +import { render, screen, act, waitFor } from "@testing-library/react" +import { useContext } from "react" +import { vi, beforeEach, describe, it, expect } from "vitest" +import { LineItems } from "#components/line_items/LineItems" +import LineItemContext from "#context/LineItemContext" + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2, name: "Baby Onesie" }, + { id: "li_2", item_type: "gift_cards", quantity: 1, name: "Gift Card" }, +] + +const mockUpdateLineItem = vi.fn().mockResolvedValue({ id: "li_1" }) +const mockDeleteLineItem = vi.fn().mockResolvedValue(undefined) +const mockReload = vi.fn().mockResolvedValue(undefined) +const mockMutate = vi.fn() + +const mockUseLineItems = vi.fn() + +vi.mock("@commercelayer/hooks", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useLineItems: (...args: unknown[]) => mockUseLineItems(...args), + } +}) + +function defaultHookReturn(overrides = {}) { + return { + lineItems: MOCK_LINE_ITEMS, + isLoading: false, + isValidating: false, + error: null, + updateLineItem: mockUpdateLineItem, + deleteLineItem: mockDeleteLineItem, + reload: mockReload, + mutate: mockMutate, + ...overrides, + } +} + +describe("LineItems component", () => { + beforeEach(() => { + mockUseLineItems.mockReturnValue(defaultHookReturn()) + mockUpdateLineItem.mockClear() + mockDeleteLineItem.mockClear() + mockReload.mockClear() + }) + + it("renders children when not loading", () => { + render( + + content + + ) + + expect(screen.getByTestId("child")).toBeDefined() + }) + + it("renders multiple children", () => { + render( + + one + two + + ) + + expect(screen.getByTestId("child-1")).toBeDefined() + expect(screen.getByTestId("child-2")).toBeDefined() + }) + + it("renders the loader while isLoading is true", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: true })) + + render( + Loading…} + > + content + + ) + + expect(screen.getByTestId("loader")).toBeDefined() + expect(screen.queryByTestId("child")).toBeNull() + }) + + it("hides the loader and shows children when isLoading becomes false", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: false })) + + render( + Loading…} + > + content + + ) + + expect(screen.queryByTestId("loader")).toBeNull() + expect(screen.getByTestId("child")).toBeDefined() + }) + + it("provides lineItems via LineItemContext to children", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + ) + + expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) + }) + + it("filters lineItems by types prop before passing to context", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + ) + + expect(capturedLineItems).toEqual([MOCK_LINE_ITEMS[0]]) + }) + + it("provides all lineItems when types prop is not set", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + ) + + expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) + }) + + it("passes accessToken and orderId to useLineItems", () => { + render( + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "my-token", orderId: "my-order" }) + ) + }) + + it("calls onUpdate callback after a successful updateLineItem", async () => { + const onUpdate = vi.fn() + let contextUpdateLineItem: ((id: string, qty?: number) => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextUpdateLineItem = ctx.updateLineItem + return null + } + + render( + + + + ) + + await act(async () => { + await contextUpdateLineItem?.("li_1", 2) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith("li_1", 2, undefined) + expect(onUpdate).toHaveBeenCalledWith("li_1") + }) + + it("calls onDelete callback after a successful deleteLineItem", async () => { + const onDelete = vi.fn() + let contextDeleteLineItem: ((id: string) => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextDeleteLineItem = ctx.deleteLineItem + return null + } + + render( + + + + ) + + await act(async () => { + await contextDeleteLineItem?.("li_1") + }) + + expect(mockDeleteLineItem).toHaveBeenCalledWith("li_1") + expect(onDelete).toHaveBeenCalledWith("li_1") + }) + + it("exposes reload function via context", async () => { + let contextReload: (() => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextReload = ctx.reload + return null + } + + render( + + + + ) + + expect(contextReload).toBeDefined() + + await act(async () => { + await contextReload?.() + }) + + expect(mockReload).toHaveBeenCalledOnce() + }) + + it("exposes error in context when hook returns an error", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ error: "Something went wrong" })) + + let capturedErrors: unknown = null + + function Consumer() { + const { errors } = useContext(LineItemContext) + capturedErrors = errors + return null + } + + render( + + + + ) + + expect(capturedErrors).toEqual([ + expect.objectContaining({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }), + ]) + }) + + it("exposes empty errors array when there is no error", () => { + let capturedErrors: unknown = null + + function Consumer() { + const { errors } = useContext(LineItemContext) + capturedErrors = errors + return null + } + + render( + + + + ) + + expect(capturedErrors).toEqual([]) + }) +}) From a34eabd43b1cb4d97ac6d971ccf423d3ad3bfcaa Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 17:45:06 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20feat(line-items):=20make=20acce?= =?UTF-8?q?ssToken=20and=20orderId=20optional,=20read=20from=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accessToken falls back to CommerceLayerContext when not provided as prop - orderId falls back to OrderContext when not provided as prop - prop values always take precedence over context values - Added 4 tests covering all fallback and precedence combinations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../specs/line_items/LineItems.spec.tsx | 64 ++++++++++++++++++- .../src/components/line_items/LineItems.tsx | 20 ++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/react-components/specs/line_items/LineItems.spec.tsx b/packages/react-components/specs/line_items/LineItems.spec.tsx index cd3e28aa..bc400500 100644 --- a/packages/react-components/specs/line_items/LineItems.spec.tsx +++ b/packages/react-components/specs/line_items/LineItems.spec.tsx @@ -1,8 +1,10 @@ -import { render, screen, act, waitFor } from "@testing-library/react" +import { render, screen, act } from "@testing-library/react" import { useContext } from "react" import { vi, beforeEach, describe, it, expect } from "vitest" import { LineItems } from "#components/line_items/LineItems" +import CommerceLayerContext from "#context/CommerceLayerContext" import LineItemContext from "#context/LineItemContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" const MOCK_LINE_ITEMS = [ { id: "li_1", item_type: "skus", quantity: 2, name: "Baby Onesie" }, @@ -168,6 +170,66 @@ describe("LineItems component", () => { ) }) + it("reads orderId from OrderContext when prop is not provided", () => { + const orderCtxValue = { ...defaultOrderContext, orderId: "ctx-order-id" } + + render( + + + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "my-token", orderId: "ctx-order-id" }) + ) + }) + + it("prop orderId takes precedence over OrderContext orderId", () => { + const orderCtxValue = { ...defaultOrderContext, orderId: "ctx-order-id" } + + render( + + + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "my-token", orderId: "prop-order-id" }) + ) + }) + + it("reads accessToken from CommerceLayerContext when prop is not provided", () => { + render( + + + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "ctx-token", orderId: "order-1" }) + ) + }) + + it("prop accessToken takes precedence over CommerceLayerContext accessToken", () => { + render( + + + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "prop-token", orderId: "order-1" }) + ) + }) + it("calls onUpdate callback after a successful updateLineItem", async () => { const onUpdate = vi.fn() let contextUpdateLineItem: ((id: string, qty?: number) => Promise) | undefined diff --git a/packages/react-components/src/components/line_items/LineItems.tsx b/packages/react-components/src/components/line_items/LineItems.tsx index c18fdc08..90cfa450 100644 --- a/packages/react-components/src/components/line_items/LineItems.tsx +++ b/packages/react-components/src/components/line_items/LineItems.tsx @@ -1,6 +1,8 @@ import { useLineItems } from "@commercelayer/hooks" -import { type JSX } from "react" +import { type JSX, useContext } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" import LineItemContext, { type LineItemContextValue } from "#context/LineItemContext" +import OrderContext from "#context/OrderContext" import type { BaseError } from "#typings/errors" import type { DefaultChildrenType } from "#typings/globals" import type { TLineItem } from "./LineItem" @@ -9,12 +11,14 @@ interface Props { children: DefaultChildrenType /** * Commerce Layer API access token. + * When omitted, the component reads it from the nearest `` context. */ - accessToken: string + accessToken?: string /** * ID of the order whose line items to display. + * When omitted, the component reads `orderId` from the nearest `` context. */ - orderId: string + orderId?: string /** * Filter line items by item type. When provided, only matching types * are put into context (affects LineItem, LineItemsCount, LineItemsEmpty). @@ -36,13 +40,19 @@ interface Props { export function LineItems({ children, - accessToken, - orderId, + accessToken: accessTokenProp, + orderId: orderIdProp, types, loader = <>Loading..., onUpdate, onDelete, }: Props): JSX.Element { + const { accessToken: contextAccessToken } = useContext(CommerceLayerContext) + const accessToken = accessTokenProp ?? contextAccessToken + + const { orderId: contextOrderId } = useContext(OrderContext) + const orderId = orderIdProp ?? contextOrderId + const { lineItems: allLineItems, isLoading, From 4df1301f861d7c50f90fb51a710c2f9ad6868b5f Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 17:59:06 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix(line-items):=20make=20ac?= =?UTF-8?q?cessToken=20optional=20in=20useLineItems=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UseLineItemsParams.accessToken is now optional (swrKey already guards fetch) - Added accessToken guard in SWR fetcher, updateLineItem, and deleteLineItem - Fixed definite assignment assertion in test to satisfy strict TS check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/hooks/src/line_items/useLineItems.test.ts | 4 ++-- packages/hooks/src/line_items/useLineItems.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/hooks/src/line_items/useLineItems.test.ts b/packages/hooks/src/line_items/useLineItems.test.ts index 720fe01c..bd55abaa 100644 --- a/packages/hooks/src/line_items/useLineItems.test.ts +++ b/packages/hooks/src/line_items/useLineItems.test.ts @@ -92,7 +92,7 @@ describe("useLineItems", () => { await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) - let updated: Awaited> + let updated!: Awaited> await act(async () => { updated = await result.current.updateLineItem("li_1", 3, false) }) @@ -100,7 +100,7 @@ describe("useLineItems", () => { expect(mockUpdateLineItem).toHaveBeenCalledWith( expect.objectContaining({ accessToken, lineItemId: "li_1", quantity: 3, hasExternalPrice: false }) ) - expect(updated!).toEqual(MOCK_UPDATED_LINE_ITEM) + expect(updated).toEqual(MOCK_UPDATED_LINE_ITEM) // SWR revalidates — getLineItems is called again expect(mockGetLineItems).toHaveBeenCalledTimes(2) }) diff --git a/packages/hooks/src/line_items/useLineItems.ts b/packages/hooks/src/line_items/useLineItems.ts index a59a676f..56560c38 100644 --- a/packages/hooks/src/line_items/useLineItems.ts +++ b/packages/hooks/src/line_items/useLineItems.ts @@ -9,7 +9,7 @@ import { useCallback } from "react" import useSWR, { type KeyedMutator } from "swr" interface UseLineItemsParams { - accessToken: string + accessToken?: string orderId?: string | null interceptors?: InterceptorManager } @@ -58,6 +58,7 @@ export function useLineItems({ const { data, error, isLoading, isValidating, mutate } = useSWR( swrKey, async (): Promise => { + if (!accessToken) throw new Error("accessToken is required") if (!orderId) throw new Error("orderId is required") return getLineItems({ accessToken, interceptors, orderId }) }, @@ -70,6 +71,7 @@ export function useLineItems({ quantity?: number, hasExternalPrice?: boolean ): Promise => { + if (!accessToken) throw new Error("accessToken is required") const updated = await coreUpdateLineItem({ accessToken, interceptors, @@ -85,6 +87,7 @@ export function useLineItems({ const deleteLineItem = useCallback( async (lineItemId: string): Promise => { + if (!accessToken) throw new Error("accessToken is required") await coreDeleteLineItem({ accessToken, interceptors, lineItemId }) await mutate() }, From 922336b9a656e9e755ab6472f420e2e78b750b61 Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 18:01:51 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(line-items):?= =?UTF-8?q?=20remove=20accessToken=20and=20orderId=20props=20from=20LineIt?= =?UTF-8?q?ems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both values are now read exclusively from context: - accessToken from CommerceLayerContext - orderId from OrderContext Updated tests to use Providers wrapper instead of direct props. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../specs/line_items/LineItems.spec.tsx | 194 ++++++++---------- .../src/components/line_items/LineItems.tsx | 19 +- 2 files changed, 87 insertions(+), 126 deletions(-) diff --git a/packages/react-components/specs/line_items/LineItems.spec.tsx b/packages/react-components/specs/line_items/LineItems.spec.tsx index bc400500..3c9f041a 100644 --- a/packages/react-components/specs/line_items/LineItems.spec.tsx +++ b/packages/react-components/specs/line_items/LineItems.spec.tsx @@ -1,5 +1,5 @@ import { render, screen, act } from "@testing-library/react" -import { useContext } from "react" +import { type ReactNode, useContext } from "react" import { vi, beforeEach, describe, it, expect } from "vitest" import { LineItems } from "#components/line_items/LineItems" import CommerceLayerContext from "#context/CommerceLayerContext" @@ -40,6 +40,24 @@ function defaultHookReturn(overrides = {}) { } } +function Providers({ + accessToken = "token", + orderId = "order-1", + children, +}: { + accessToken?: string + orderId?: string + children: ReactNode +}) { + return ( + + + {children} + + + ) +} + describe("LineItems component", () => { beforeEach(() => { mockUseLineItems.mockReturnValue(defaultHookReturn()) @@ -50,9 +68,11 @@ describe("LineItems component", () => { it("renders children when not loading", () => { render( - - content - + + + content + + ) expect(screen.getByTestId("child")).toBeDefined() @@ -60,10 +80,12 @@ describe("LineItems component", () => { it("renders multiple children", () => { render( - - one - two - + + + one + two + + ) expect(screen.getByTestId("child-1")).toBeDefined() @@ -74,13 +96,11 @@ describe("LineItems component", () => { mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: true })) render( - Loading…} - > - content - + + Loading…}> + content + + ) expect(screen.getByTestId("loader")).toBeDefined() @@ -91,13 +111,11 @@ describe("LineItems component", () => { mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: false })) render( - Loading…} - > - content - + + Loading…}> + content + + ) expect(screen.queryByTestId("loader")).toBeNull() @@ -114,9 +132,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) @@ -132,9 +152,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) expect(capturedLineItems).toEqual([MOCK_LINE_ITEMS[0]]) @@ -150,83 +172,27 @@ describe("LineItems component", () => { } render( - - - - ) - - expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) - }) - - it("passes accessToken and orderId to useLineItems", () => { - render( - - - - ) - - expect(mockUseLineItems).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "my-token", orderId: "my-order" }) - ) - }) - - it("reads orderId from OrderContext when prop is not provided", () => { - const orderCtxValue = { ...defaultOrderContext, orderId: "ctx-order-id" } - - render( - - - + + + - + ) - expect(mockUseLineItems).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "my-token", orderId: "ctx-order-id" }) - ) - }) - - it("prop orderId takes precedence over OrderContext orderId", () => { - const orderCtxValue = { ...defaultOrderContext, orderId: "ctx-order-id" } - - render( - - - - - - ) - - expect(mockUseLineItems).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "my-token", orderId: "prop-order-id" }) - ) - }) - - it("reads accessToken from CommerceLayerContext when prop is not provided", () => { - render( - - - - - - ) - - expect(mockUseLineItems).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "ctx-token", orderId: "order-1" }) - ) + expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) }) - it("prop accessToken takes precedence over CommerceLayerContext accessToken", () => { + it("reads accessToken and orderId from context and passes them to useLineItems", () => { render( - - + + - + ) expect(mockUseLineItems).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "prop-token", orderId: "order-1" }) + expect.objectContaining({ accessToken: "ctx-token", orderId: "ctx-order" }) ) }) @@ -241,9 +207,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) await act(async () => { @@ -265,9 +233,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) await act(async () => { @@ -288,9 +258,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) expect(contextReload).toBeDefined() @@ -314,9 +286,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) expect(capturedErrors).toEqual([ @@ -337,9 +311,11 @@ describe("LineItems component", () => { } render( - - - + + + + + ) expect(capturedErrors).toEqual([]) diff --git a/packages/react-components/src/components/line_items/LineItems.tsx b/packages/react-components/src/components/line_items/LineItems.tsx index 90cfa450..10be47f0 100644 --- a/packages/react-components/src/components/line_items/LineItems.tsx +++ b/packages/react-components/src/components/line_items/LineItems.tsx @@ -9,16 +9,6 @@ import type { TLineItem } from "./LineItem" interface Props { children: DefaultChildrenType - /** - * Commerce Layer API access token. - * When omitted, the component reads it from the nearest `` context. - */ - accessToken?: string - /** - * ID of the order whose line items to display. - * When omitted, the component reads `orderId` from the nearest `` context. - */ - orderId?: string /** * Filter line items by item type. When provided, only matching types * are put into context (affects LineItem, LineItemsCount, LineItemsEmpty). @@ -40,18 +30,13 @@ interface Props { export function LineItems({ children, - accessToken: accessTokenProp, - orderId: orderIdProp, types, loader = <>Loading..., onUpdate, onDelete, }: Props): JSX.Element { - const { accessToken: contextAccessToken } = useContext(CommerceLayerContext) - const accessToken = accessTokenProp ?? contextAccessToken - - const { orderId: contextOrderId } = useContext(OrderContext) - const orderId = orderIdProp ?? contextOrderId + const { accessToken } = useContext(CommerceLayerContext) + const { orderId } = useContext(OrderContext) const { lineItems: allLineItems, From 156b24d829fa23f4818feecd2f8d6cb63db68217 Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Mon, 25 May 2026 18:09:27 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20test(line-items):=20achieve=201?= =?UTF-8?q?00%=20coverage=20on=20useLineItems=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead guards from SWR fetcher (swrKey already prevents unreachable paths) - Add tests: returns empty when accessToken is undefined - Add tests: updateLineItem and deleteLineItem throw when accessToken is missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../hooks/src/line_items/useLineItems.test.ts | 26 +++++++++++++------ packages/hooks/src/line_items/useLineItems.ts | 4 +-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/hooks/src/line_items/useLineItems.test.ts b/packages/hooks/src/line_items/useLineItems.test.ts index bd55abaa..f5b55c8a 100644 --- a/packages/hooks/src/line_items/useLineItems.test.ts +++ b/packages/hooks/src/line_items/useLineItems.test.ts @@ -182,19 +182,29 @@ describe("useLineItems", () => { expect(mockUpdateLineItem).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) }) - it("passes interceptors to deleteLineItem", async () => { - const interceptors: InterceptorManager = { - request: { onSuccess: vi.fn((req) => req) }, - } + it("returns empty array and no loading when accessToken is undefined", () => { + const { result } = renderHook(() => useLineItems({ orderId }), { + wrapper: swrWrapper, + }) - const { result } = renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("updateLineItem throws when accessToken is not provided", async () => { + const { result } = renderHook(() => useLineItems({ orderId }), { wrapper: swrWrapper, }) - await act(async () => { - await result.current.deleteLineItem("li_1") + await expect(result.current.updateLineItem("li_1", 2)).rejects.toThrow("accessToken is required") + }) + + it("deleteLineItem throws when accessToken is not provided", async () => { + const { result } = renderHook(() => useLineItems({ orderId }), { + wrapper: swrWrapper, }) - expect(mockDeleteLineItem).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + await expect(result.current.deleteLineItem("li_1")).rejects.toThrow("accessToken is required") }) }) diff --git a/packages/hooks/src/line_items/useLineItems.ts b/packages/hooks/src/line_items/useLineItems.ts index 56560c38..7e5a73e1 100644 --- a/packages/hooks/src/line_items/useLineItems.ts +++ b/packages/hooks/src/line_items/useLineItems.ts @@ -58,9 +58,7 @@ export function useLineItems({ const { data, error, isLoading, isValidating, mutate } = useSWR( swrKey, async (): Promise => { - if (!accessToken) throw new Error("accessToken is required") - if (!orderId) throw new Error("orderId is required") - return getLineItems({ accessToken, interceptors, orderId }) + return getLineItems({ accessToken: accessToken as string, interceptors, orderId: orderId as string }) }, { revalidateOnFocus: false, revalidateOnReconnect: false } )