Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions packages/core/src/in_stock_subscriptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { setInStockSubscription } from "./setInStockSubscription"
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, test, vi } from "vitest"
import type { InterceptorManager } from "#sdk"
import { setInStockSubscription } from "./setInStockSubscription.js"

const {
mockCreate,
mockAddRequestInterceptor,
mockAddResponseInterceptor,
mockAddRawResponseReader,
} = vi.hoisted(() => {
const mockCreate = vi.fn()
const mockAddRequestInterceptor = vi.fn().mockReturnValue(1)
const mockAddResponseInterceptor = vi.fn().mockReturnValue(1)
const mockAddRawResponseReader = vi.fn()
return {
mockCreate,
mockAddRequestInterceptor,
mockAddResponseInterceptor,
mockAddRawResponseReader,
}
})

vi.mock("@commercelayer/sdk/bundle", () => ({
CommerceLayer: vi.fn().mockReturnValue({
addRequestInterceptor: mockAddRequestInterceptor,
addResponseInterceptor: mockAddResponseInterceptor,
addRawResponseReader: mockAddRawResponseReader,
in_stock_subscriptions: { create: mockCreate },
}),
}))

vi.mock("@commercelayer/js-auth", () => ({
jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }),
}))

describe("setInStockSubscription", () => {
beforeEach(() => {
vi.clearAllMocks()
mockAddRequestInterceptor.mockReturnValue(1)
mockAddResponseInterceptor.mockReturnValue(1)
mockCreate.mockResolvedValue(undefined)
})

test("resolves without a return value", async () => {
const result = await setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
})

expect(result).toBeUndefined()
})

test("calls in_stock_subscriptions.create with sku_code only when no customerEmail", async () => {
await setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
})

expect(mockCreate).toHaveBeenCalledWith({ sku_code: "TSHIRTMS000000FFFFFFXLXX" })
})

test("includes customer_email when provided", async () => {
await setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
customerEmail: "test@example.com",
})

expect(mockCreate).toHaveBeenCalledWith({
sku_code: "TSHIRTMS000000FFFFFFXLXX",
customer_email: "test@example.com",
})
})

test("throws when the API call fails", async () => {
mockCreate.mockRejectedValue(new Error("API error"))

await expect(
setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
})
).rejects.toThrow("API error")
})

test("forwards request interceptors to getSdk", async () => {
const onSuccess = vi.fn()
const interceptors: InterceptorManager = { request: { onSuccess } }

await setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
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 setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
interceptors,
})

expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined)
expect(mockAddRequestInterceptor).not.toHaveBeenCalled()
})

test("does not register any interceptors when none are provided", async () => {
await setInStockSubscription({
accessToken: "fake-token",
skuCode: "TSHIRTMS000000FFFFFFXLXX",
})

expect(mockAddRequestInterceptor).not.toHaveBeenCalled()
expect(mockAddResponseInterceptor).not.toHaveBeenCalled()
expect(mockAddRawResponseReader).not.toHaveBeenCalled()
})
})
38 changes: 38 additions & 0 deletions packages/core/src/in_stock_subscriptions/setInStockSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { InStockSubscriptionCreate } from "@commercelayer/sdk"
import { getSdk } from "#sdk"
import type { RequestConfig } from "#types"

interface SetInStockSubscriptionParams extends Pick<RequestConfig, "accessToken" | "interceptors"> {
/**
* The email of the customer to subscribe. If omitted, the JWT owner email is used.
*/
customerEmail?: string
/**
* The SKU code to watch for availability.
*/
skuCode: string
}

/**
* Create an in-stock subscription for a given SKU.
*
* @param {string} accessToken - The access token to use for authentication.
* @param {string} skuCode - The SKU code to subscribe to.
* @param {string} [customerEmail] - The customer email. If omitted, the JWT owner email is used.
*/
export async function setInStockSubscription({
accessToken,
interceptors,
customerEmail,
skuCode,
}: SetInStockSubscriptionParams): Promise<void> {
const sdk = getSdk({ accessToken, interceptors })
// @ts-expect-error OpenAPI schema is not updated yet for in_stock_subscriptions
const attributes: InStockSubscriptionCreate = {
sku_code: skuCode,
}
if (customerEmail != null) {
attributes.customer_email = customerEmail
}
await sdk.in_stock_subscriptions.create(attributes)
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./availability"
export * from "./createBatchStore"
export * from "./customers"
export * from "./gift_cards"
export * from "./in_stock_subscriptions"
export * from "./line_items"
export * from "./orders"
export * from "./prices"
Expand Down
2 changes: 2 additions & 0 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import path from "node:path"
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"

export default defineConfig({
test: {
name: "core",
environment: "node",
envDir: path.resolve(__dirname, "../.."),
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
Expand Down
7 changes: 6 additions & 1 deletion packages/hooks/src/gift_cards/useGiftCards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { act, renderHook, waitFor } from "@testing-library/react"
import type { ReactNode } from "react"
import { createElement } from "react"
import { SWRConfig } from "swr"
import { describe, expect } from "vitest"
import { beforeEach, describe, expect } from "vitest"
import { coreIntegrationTest } from "#extender"
import { useGiftCards } from "./useGiftCards"

const swrWrapper = ({ children }: { children: ReactNode }) =>
createElement(SWRConfig, { value: { provider: () => new Map() } }, children)

const domain = import.meta.env.VITE_DOMAIN

describe("useGiftCards", () => {
beforeEach(({ skip }) => {
if (domain == null) skip()
})
coreIntegrationTest("should start with empty state", async ({ accessToken }) => {
const token = accessToken?.accessToken
const { result } = renderHook(() => useGiftCards(token))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { InterceptorManager } from "../index"
import { useInStockSubscriptions } from "./useInStockSubscriptions"

const mockCoreSetInStockSubscription = vi.fn().mockResolvedValue(undefined)

vi.mock("@commercelayer/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@commercelayer/core")>()
return {
...actual,
setInStockSubscription: (...args: unknown[]) => mockCoreSetInStockSubscription(...args),
}
})

describe("useInStockSubscriptions", () => {
const accessToken = "test-token"
const skuCode = "TSHIRTMS000000FFFFFFXLXX"

beforeEach(() => {
mockCoreSetInStockSubscription.mockClear()
mockCoreSetInStockSubscription.mockResolvedValue(undefined)
})

it("starts with isLoading false", () => {
const { result } = renderHook(() => useInStockSubscriptions({ accessToken }))
expect(result.current.isLoading).toBe(false)
})

it("sets isLoading true while the API call is in flight", async () => {
let resolveApi!: () => void
mockCoreSetInStockSubscription.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveApi = resolve
})
)

const { result } = renderHook(() => useInStockSubscriptions({ accessToken }))

act(() => {
void result.current.setInStockSubscription({ skuCode })
})

await waitFor(() => expect(result.current.isLoading).toBe(true))

await act(async () => {
resolveApi()
})

await waitFor(() => expect(result.current.isLoading).toBe(false))
})

it("calls core function with accessToken and skuCode", async () => {
const { result } = renderHook(() => useInStockSubscriptions({ accessToken }))

await act(async () => {
await result.current.setInStockSubscription({ skuCode })
})

expect(mockCoreSetInStockSubscription).toHaveBeenCalledWith(
expect.objectContaining({ accessToken, skuCode })
)
})

it("calls core function with customerEmail when provided", async () => {
const { result } = renderHook(() => useInStockSubscriptions({ accessToken }))

await act(async () => {
await result.current.setInStockSubscription({ skuCode, customerEmail: "test@example.com" })
})

expect(mockCoreSetInStockSubscription).toHaveBeenCalledWith(
expect.objectContaining({ accessToken, skuCode, customerEmail: "test@example.com" })
)
})

it("throws and resets isLoading when accessToken is missing", async () => {
const { result } = renderHook(() => useInStockSubscriptions({}))

await expect(
act(async () => {
await result.current.setInStockSubscription({ skuCode })
})
).rejects.toThrow("accessToken is required")

expect(result.current.isLoading).toBe(false)
})

it("resets isLoading to false after a failed API call", async () => {
mockCoreSetInStockSubscription.mockRejectedValueOnce(new Error("API error"))

const { result } = renderHook(() => useInStockSubscriptions({ accessToken }))

await expect(
act(async () => {
await result.current.setInStockSubscription({ skuCode })
})
).rejects.toThrow("API error")

expect(result.current.isLoading).toBe(false)
})

it("passes interceptors to the core function", async () => {
const interceptors: InterceptorManager = {
request: { onSuccess: vi.fn((req) => req) },
}
const { result } = renderHook(() => useInStockSubscriptions({ accessToken, interceptors }))

await act(async () => {
await result.current.setInStockSubscription({ skuCode })
})

expect(mockCoreSetInStockSubscription).toHaveBeenCalledWith(
expect.objectContaining({ interceptors })
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
setInStockSubscription as coreSetInStockSubscription,
type InterceptorManager,
} from "@commercelayer/core"
import { useCallback, useState } from "react"

interface UseInStockSubscriptionsParams {
accessToken?: string
interceptors?: InterceptorManager
}

interface UseInStockSubscriptionsReturn {
isLoading: boolean
setInStockSubscription: (params: { customerEmail?: string; skuCode: string }) => Promise<void>
}

/**
* React hook for creating in-stock subscriptions in Commerce Layer.
*
* @param accessToken - Commerce Layer API access token.
* @param interceptors - Optional SDK interceptors.
*
* @example
* ```tsx
* const { isLoading, setInStockSubscription } = useInStockSubscriptions({ accessToken })
* await setInStockSubscription({ skuCode: 'TSHIRTMS000000FFFFFFXLXX' })
* ```
*/
export function useInStockSubscriptions({
accessToken,
interceptors,
}: UseInStockSubscriptionsParams): UseInStockSubscriptionsReturn {
const [isLoading, setIsLoading] = useState(false)

const setInStockSubscription = useCallback(
async ({
customerEmail,
skuCode,
}: {
customerEmail?: string
skuCode: string
}): Promise<void> => {
if (!accessToken) throw new Error("accessToken is required")
setIsLoading(true)
try {
await coreSetInStockSubscription({ accessToken, interceptors, customerEmail, skuCode })
} finally {
setIsLoading(false)
}
},
[accessToken, interceptors]
)

return { isLoading, setInStockSubscription }
}
3 changes: 2 additions & 1 deletion packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ export type { InterceptorManager } from "@commercelayer/core"
export { useAvailability } from "./availability/useAvailability"
export { useCustomer } from "./customers/useCustomer"
export { useGiftCards } from "./gift_cards/useGiftCards"
export { useInStockSubscriptions } from "./in_stock_subscriptions/useInStockSubscriptions"
export { useLineItems } from "./line_items/useLineItems"
export { useOrder } from "./orders/useOrder"
export { usePrices } from "./prices/usePrices"
export { useShipments } from "./shipments/useShipments"
export { useSkuList } from "./sku_lists/useSkuList"
export { useSkuLists } from "./sku_lists/useSkuLists"
export { useShipments } from "./shipments/useShipments"
export type { Sku, SkuUpdate } from "./skus/index"
export { useSkus } from "./skus/useSkus"
Loading
Loading