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
21 changes: 20 additions & 1 deletion .agents/skills/apps-nuvemshop/references/cart-and-checkout.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Cart and checkout

## The supported path (API)

The cart lives on the client (`hooks/useCart`: items in `localStorage`), the
minicart reads `loaders/cart` (`useCartDetails`), and "finalizar" calls
`actions/createCheckout` → `POST /checkouts` → hosted `checkout_url`.

- `cart` fetches the products by `ids` (≤30 per call, cached) and returns lines
with name, variant, image, price/list price, subtotal, `maxQuantity`, plus
totals and savings. It **clamps quantity to stock**: the API answers
`422 checkout_rejected` for a line above stock. Missing/hidden variants and
sold-out ones go to `unavailable` instead of the totals.
- `createCheckout` returns `{ checkoutUrl }` or `{ error, message }`:
`422 coupon_rejected` → `coupon_rejected`, `422 checkout_rejected` →
`out_of_stock`, `404 resource_not_found` → `unavailable`, `400` → `invalid`.
`useCart().checkout()` throws `CheckoutError(code)` so the UI can react.
- No coupon preview before the checkout (the API has none); the checkout's
totals equal the `cart` loader's subtotal (checked live).

## What exists

- **API**: `POST /checkouts {line_items, coupon_code?}` → `checkout_url`
Expand All @@ -24,7 +42,8 @@ challenge, including a real browser clicking the theme's button, for 35+ min.
No merchant setting changes it. A server-side proxy sends every buyer through
the Worker's egress IPs, so this can block a whole store's add-to-cart. The
API path (`/checkouts` with a Storefront token + buyer IP) doesn't have this.
Ask Nuvemshop before shipping a classic-cart proxy.
Ask Nuvemshop before shipping a classic-cart proxy — that's why the API path
above is the supported one.

## Checkout proxy (spike)

Expand Down
118 changes: 118 additions & 0 deletions packages/apps-nuvemshop/src/__tests__/cart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import browseFixture from "../__fixtures__/browse-category.json";
import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../client";
import cart from "../loaders/cart";

// browse fixture: [0] camisa-xadrez 189.90 (stock 17,20,…), [1] camisa-flanela 169.90 promo / 199.90, [2] camiseta-gamer 89.90
const [xadrez, flanela, gamer] = browseFixture.data;
const SITE = "https://www.loja.example";
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input));
const ids = (url.searchParams.get("ids") ?? "").split(",").map(Number);
const data = browseFixture.data.filter((p) => ids.includes(p.id));
return new Response(
JSON.stringify({
data,
pagination: { page: 1, per_page: 30, total: data.length, total_pages: 1 },
}),
);
});
const item = (p: (typeof browseFixture.data)[number], v = 0, quantity = 1) => ({
productId: p.id,
variantId: p.variants[v].id,
quantity,
});

beforeEach(() => {
fetchMock.mockClear();
clearNuvemshopCache();
configureNuvemshop({ storeId: "8336778" });
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
});

describe("cart loader", () => {
it("resolves each line from the Storefront API with prices, image and variant", async () => {
const c = await cart({ items: [item(xadrez, 0, 2), item(flanela, 5)], __pageUrl: `${SITE}/` });
expect(c.lines).toHaveLength(2);
expect(c.lines[0]).toMatchObject({
productId: xadrez.id,
variantId: xadrez.variants[0].id,
name: "Camisa Xadrez Lenhador Azul",
variantName: "P / Azul e Preto",
quantity: 2,
maxQuantity: 17,
price: 189.9,
listPrice: 189.9,
subtotal: 379.8,
url: `${SITE}/produtos/camisa-xadrez-lenhador-azul?variant=${xadrez.variants[0].id}`,
});
expect(c.lines[1]).toMatchObject({
variantName: "M / Cinza",
price: 169.9,
listPrice: 199.9,
subtotal: 169.9,
});
expect(c.lines[1].image).toBe(
flanela.images.find((i) => i.id === flanela.variants[5].image_id)!.src,
);
expect(c).toMatchObject({
itemCount: 3,
subtotal: 549.7,
listSubtotal: 579.7,
savings: 30,
currency: "BRL",
unavailable: [],
});
});

it("asks only for the products in the cart (ids, deduped) and nothing for an empty cart", async () => {
await cart({ items: [item(xadrez, 0), item(xadrez, 1), item(gamer)] });
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.searchParams.get("ids")).toBe(`${xadrez.id},${gamer.id}`);
expect(url.searchParams.get("per_page")).toBe("30");

fetchMock.mockClear();
expect(await cart({ items: [] })).toMatchObject({ lines: [], itemCount: 0, subtotal: 0 });
expect(fetchMock).not.toHaveBeenCalled();
});

it("clamps quantities to stock (the checkout rejects more) and to 99", async () => {
const c = await cart({ items: [{ ...item(xadrez, 0), quantity: 50 }] });
expect(c.lines[0]).toMatchObject({ quantity: 17, maxQuantity: 17, adjusted: true });
});

it("moves missing and sold-out variants out of the lines", async () => {
const soldOut = { ...item(gamer, 0), variantId: gamer.variants[0].id };
fetchMock.mockImplementationOnce(async () =>
Response.json({
data: [
{ ...gamer, variants: gamer.variants.map((v, i) => (i === 0 ? { ...v, stock: 0 } : v)) },
],
pagination: { page: 1, per_page: 30, total: 1, total_pages: 1 },
}),
);
const c = await cart({
items: [
soldOut,
{ productId: gamer.id, variantId: 999, quantity: 1 },
{ productId: 1, variantId: 2, quantity: 1 },
],
});
expect(c.lines).toEqual([]);
expect(c.unavailable).toEqual([
{ ...soldOut, reason: "out_of_stock" },
{ productId: gamer.id, variantId: 999, quantity: 1, reason: "not_found" },
{ productId: 1, variantId: 2, quantity: 1, reason: "not_found" },
]);
});

it("fetches in batches of 30 ids (the API limit)", async () => {
const items = Array.from({ length: 31 }, (_, i) => ({
productId: i + 1,
variantId: 1,
quantity: 1,
}));
await cart({ items });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
25 changes: 22 additions & 3 deletions packages/apps-nuvemshop/src/__tests__/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,35 @@ describe("createCheckout", () => {
items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }],
coupon: " TEST ",
});
expect(res.checkoutUrl).toBe(checkoutFixture.checkout_url);
expect(res).toEqual({ checkoutUrl: checkoutFixture.checkout_url });
const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body));
expect(body).toEqual({
line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }],
coupon_code: "TEST",
});
});

it("rejects an empty cart without calling the API", async () => {
await expect(createCheckout({ items: [] })).rejects.toThrow(/empty/);
it("returns a typed error for an empty cart without calling the API", async () => {
expect(await createCheckout({ items: [] })).toEqual({
error: "invalid",
message: "Cannot checkout an empty cart",
});
expect(fetchMock).not.toHaveBeenCalled();
});

it.each([
[422, "coupon_rejected", "coupon_rejected"],
[422, "checkout_rejected", "out_of_stock"],
[404, "resource_not_found", "unavailable"],
[400, "invalid_request", "invalid"],
[500, "boom", "unknown"],
])("maps the API's %i %s to a typed error (%s)", async (status, code, error) => {
fetchMock.mockImplementationOnce(async () =>
json({ error: { code, message: `msg ${code}` } }, status),
);
expect(await createCheckout({ items: [{ productId: 1, variantId: 2, quantity: 1 }] })).toEqual({
error,
message: `msg ${code}`,
});
});
});
46 changes: 37 additions & 9 deletions packages/apps-nuvemshop/src/actions/createCheckout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { nuvemshopPost } from "../client";
import { NuvemshopApiError, nuvemshopPost } from "../client";
import type { NuvemshopCheckout, NuvemshopLineItem } from "../utils/types";

export interface CartItem {
Expand All @@ -12,25 +12,53 @@ export interface Props {
coupon?: string;
}

export type CheckoutErrorCode =
| "coupon_rejected"
| "out_of_stock"
| "unavailable"
| "invalid"
| "unknown";

export type CreateCheckoutResult =
| { checkoutUrl: string }
| { error: CheckoutErrorCode; message: string };

// API error code → what the buyer can act on.
const ERRORS: Record<string, CheckoutErrorCode> = {
coupon_rejected: "coupon_rejected",
checkout_rejected: "out_of_stock", // returned when a line exceeds the variant's stock
resource_not_found: "unavailable",
invalid_request: "invalid",
};

/**
* @title Nuvemshop - Create Checkout
* @description Opens a Nuvemshop cart with the items (and coupon) and returns the hosted checkout URL.
* Failures come back as `{ error, message }` (rejected coupon, stock, removed variant) instead of throwing.
*/
export default async function createCheckout(
props: Props,
req?: Request,
): Promise<{ checkoutUrl: string }> {
if (!props.items?.length) throw new Error("Cannot checkout an empty cart");
): Promise<CreateCheckoutResult> {
if (!props?.items?.length) return { error: "invalid", message: "Cannot checkout an empty cart" };
const line_items: NuvemshopLineItem[] = props.items.map((i) => ({
product_id: i.productId,
variant_id: i.variantId,
quantity: i.quantity,
}));
const coupon_code = props.coupon?.trim() || undefined;
const res = await nuvemshopPost<NuvemshopCheckout>(
"/checkouts",
{ line_items, coupon_code },
{ buyerIp: req?.headers.get("cf-connecting-ip") },
);
return { checkoutUrl: res.checkout_url };
try {
const res = await nuvemshopPost<NuvemshopCheckout>(
"/checkouts",
{ line_items, coupon_code },
{ buyerIp: req?.headers.get("cf-connecting-ip") },
);
return { checkoutUrl: res.checkout_url };
} catch (e) {
if (!(e instanceof NuvemshopApiError)) throw e;
return {
error: ERRORS[e.code] ?? "unknown",
message: e.message.replace(/^Nuvemshop \d+ [\w-]*: /, ""),
};
}
}
18 changes: 16 additions & 2 deletions packages/apps-nuvemshop/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,26 @@ function headers(extra?: Record<string, string>) {
};
}

/** A non-2xx Storefront API response, with the API's `error.code` (e.g. `coupon_rejected`). */
export class NuvemshopApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(`Nuvemshop ${status} ${code}: ${message}`);
this.name = "NuvemshopApiError";
}
}

async function errorFrom(res: Response) {
const body = (await res.json().catch(() => null)) as {
error?: { code?: string; message?: string };
} | null;
return new Error(
`Nuvemshop ${res.status} ${body?.error?.code ?? ""}: ${body?.error?.message ?? res.statusText}`,
return new NuvemshopApiError(
res.status,
body?.error?.code ?? "",
body?.error?.message ?? res.statusText,
);
}

Expand Down
37 changes: 37 additions & 0 deletions packages/apps-nuvemshop/src/hooks/__tests__/useCart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addItem,
CART_STORAGE_KEY,
CheckoutError,
cartStore,
checkout,
fetchCartDetails,
removeItem,
setQuantity,
} from "../useCart";
Expand Down Expand Up @@ -69,6 +71,20 @@ describe("checkout", () => {
fetchSpy.mockRestore();
});

it("throws a typed CheckoutError (e.g. rejected coupon) instead of navigating", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ error: "coupon_rejected", message: "nope" })),
);
const navigate = vi.fn();
const err = await checkout([shirt], "BAD", navigate).catch((e) => e);
expect(err).toBeInstanceOf(CheckoutError);
expect(err.code).toBe("coupon_rejected");
expect(navigate).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});

it("throws the server error instead of navigating", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
Expand All @@ -79,3 +95,24 @@ describe("checkout", () => {
fetchSpy.mockRestore();
});
});

describe("fetchCartDetails", () => {
it("invokes the cart loader with the items and returns its result", async () => {
const details = { lines: [], itemCount: 0, subtotal: 0 };
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify(details)));
expect(await fetchCartDetails([shirt])).toEqual(details);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe("/deco/invoke/nuvemshop/loaders/cart");
expect(JSON.parse(String(init!.body))).toEqual({ items: [shirt] });
fetchSpy.mockRestore();
});

it("skips the request for an empty cart", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
expect(await fetchCartDetails([])).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
Loading
Loading