@decocms/apps-nuvemshop 7.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +48 -0
- package/src/__fixtures__/browse-category.json +813 -0
- package/src/__fixtures__/categories.json +60 -0
- package/src/__fixtures__/checkout.json +3 -0
- package/src/__fixtures__/product-custom-fields.json +199 -0
- package/src/__fixtures__/product.json +313 -0
- package/src/__fixtures__/search.json +636 -0
- package/src/__fixtures__/shipping-options.json +9 -0
- package/src/__tests__/client.test.ts +118 -0
- package/src/__tests__/loaders.test.ts +220 -0
- package/src/__tests__/mod.test.ts +51 -0
- package/src/actions/createCheckout.ts +36 -0
- package/src/client.ts +165 -0
- package/src/hooks/__tests__/useCart.test.ts +81 -0
- package/src/hooks/useCart.ts +115 -0
- package/src/index.ts +23 -0
- package/src/loaders/categories.ts +27 -0
- package/src/loaders/productDetailsPage.ts +30 -0
- package/src/loaders/productList.ts +49 -0
- package/src/loaders/productListingPage.ts +94 -0
- package/src/loaders/relatedProducts.ts +41 -0
- package/src/loaders/shippingOptions.ts +29 -0
- package/src/loaders/suggestions.ts +24 -0
- package/src/manifest.gen.ts +31 -0
- package/src/mod.ts +42 -0
- package/src/registry.ts +9 -0
- package/src/utils/__tests__/listing.test.ts +167 -0
- package/src/utils/__tests__/transform.test.ts +212 -0
- package/src/utils/instrumentedFetch.ts +40 -0
- package/src/utils/listing.ts +183 -0
- package/src/utils/operationRouter.ts +21 -0
- package/src/utils/request.ts +17 -0
- package/src/utils/transform.ts +231 -0
- package/src/utils/types.ts +141 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import productFixture from "../__fixtures__/product.json";
|
|
3
|
+
import {
|
|
4
|
+
CATEGORY_FIELDS,
|
|
5
|
+
clearNuvemshopCache,
|
|
6
|
+
configureNuvemshop,
|
|
7
|
+
nuvemshopGet,
|
|
8
|
+
nuvemshopPost,
|
|
9
|
+
PRODUCT_FIELDS,
|
|
10
|
+
setNuvemshopFetch,
|
|
11
|
+
} from "../client";
|
|
12
|
+
|
|
13
|
+
const API = "https://storefront-api.tiendanube.com/v2026-11/stores/8336778";
|
|
14
|
+
|
|
15
|
+
function mockFetch(body: unknown = productFixture, status = 200) {
|
|
16
|
+
const fn = vi.fn(
|
|
17
|
+
async (_url: string | URL | Request, _init?: RequestInit) =>
|
|
18
|
+
new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
setNuvemshopFetch(fn as unknown as typeof fetch);
|
|
24
|
+
return fn;
|
|
25
|
+
}
|
|
26
|
+
const call = (fn: ReturnType<typeof mockFetch>, i = 0) => ({
|
|
27
|
+
url: new URL(String(fn.mock.calls[i][0])),
|
|
28
|
+
headers: new Headers(fn.mock.calls[i][1]?.headers),
|
|
29
|
+
init: fn.mock.calls[i][1],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
clearNuvemshopCache();
|
|
34
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("nuvemshopGet", () => {
|
|
38
|
+
it("targets the versioned store URL and passes params through", async () => {
|
|
39
|
+
const fn = mockFetch();
|
|
40
|
+
await nuvemshopGet("/products", { category_id: 41376194, page: 2, ids: undefined });
|
|
41
|
+
const { url } = call(fn);
|
|
42
|
+
expect(`${url.origin}${url.pathname}`).toBe(`${API}/products`);
|
|
43
|
+
expect(url.searchParams.get("category_id")).toBe("41376194");
|
|
44
|
+
expect(url.searchParams.get("page")).toBe("2");
|
|
45
|
+
expect(url.searchParams.has("ids")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("always asks for the full product field set on product routes", async () => {
|
|
49
|
+
const fn = mockFetch();
|
|
50
|
+
await nuvemshopGet("/products/camisa");
|
|
51
|
+
await nuvemshopGet("/search/products", { q: "x" });
|
|
52
|
+
expect(call(fn, 0).url.searchParams.get("fields")).toBe(PRODUCT_FIELDS);
|
|
53
|
+
expect(call(fn, 1).url.searchParams.get("fields")).toBe(PRODUCT_FIELDS);
|
|
54
|
+
expect(PRODUCT_FIELDS.split(",")).toEqual(
|
|
55
|
+
expect.arrayContaining(["variants", "images", "attributes"]),
|
|
56
|
+
);
|
|
57
|
+
expect(PRODUCT_FIELDS).not.toContain("cost");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("asks for parent/subcategories on category routes (the default selection omits them)", async () => {
|
|
61
|
+
const fn = mockFetch();
|
|
62
|
+
await nuvemshopGet("/categories", { per_page: 200 });
|
|
63
|
+
await nuvemshopGet("/categories/calcados");
|
|
64
|
+
for (const i of [0, 1]) {
|
|
65
|
+
expect(call(fn, i).url.searchParams.get("fields")).toBe(CATEGORY_FIELDS);
|
|
66
|
+
}
|
|
67
|
+
expect(CATEGORY_FIELDS.split(",")).toEqual(expect.arrayContaining(["parent", "subcategories"]));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is tokenless by default and sends a bearer token when configured", async () => {
|
|
71
|
+
const fn = mockFetch();
|
|
72
|
+
await nuvemshopGet("/categories");
|
|
73
|
+
expect(call(fn).headers.has("authorization")).toBe(false);
|
|
74
|
+
|
|
75
|
+
configureNuvemshop({ storeId: "8336778", token: "secret" });
|
|
76
|
+
await nuvemshopGet("/categories", { page: 2 }); // same URL would be a cache hit
|
|
77
|
+
expect(call(fn, 1).headers.get("authorization")).toBe("Bearer secret");
|
|
78
|
+
expect(call(fn, 1).headers.has("x-linkedstore-buyer-ip")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("dedups identical GETs through the SWR cache", async () => {
|
|
82
|
+
const fn = mockFetch();
|
|
83
|
+
await Promise.all([nuvemshopGet("/products/a"), nuvemshopGet("/products/a")]);
|
|
84
|
+
await nuvemshopGet("/products/a");
|
|
85
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns null on 404", async () => {
|
|
89
|
+
mockFetch({ error: { code: "resource_not_found", message: "nope" } }, 404);
|
|
90
|
+
expect(await nuvemshopGet("/products/missing")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws when not configured", async () => {
|
|
94
|
+
configureNuvemshop(null);
|
|
95
|
+
await expect(nuvemshopGet("/products")).rejects.toThrow(/not configured/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("nuvemshopPost", () => {
|
|
100
|
+
it("posts JSON and forwards the buyer IP only with a token", async () => {
|
|
101
|
+
const fn = mockFetch({ checkout_url: "https://x" });
|
|
102
|
+
await nuvemshopPost("/checkouts", { line_items: [] }, { buyerIp: "203.0.113.10" });
|
|
103
|
+
expect(call(fn).init?.method).toBe("POST");
|
|
104
|
+
expect(call(fn).headers.has("x-linkedstore-buyer-ip")).toBe(false);
|
|
105
|
+
|
|
106
|
+
configureNuvemshop({ storeId: "8336778", token: "secret" });
|
|
107
|
+
await nuvemshopPost("/checkouts", { line_items: [] }, { buyerIp: "203.0.113.10" });
|
|
108
|
+
expect(call(fn, 1).headers.get("x-linkedstore-buyer-ip")).toBe("203.0.113.10");
|
|
109
|
+
expect(JSON.parse(String(call(fn, 1).init?.body))).toEqual({ line_items: [] });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("surfaces the API error message", async () => {
|
|
113
|
+
mockFetch({ error: { code: "invalid_body", message: "The request body is invalid." } }, 400);
|
|
114
|
+
await expect(nuvemshopPost("/checkouts", {})).rejects.toThrow(
|
|
115
|
+
/invalid_body.*The request body is invalid/,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ProductListingPage } from "@decocms/apps-commerce/types";
|
|
2
|
+
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
3
|
+
import browseFixture from "../__fixtures__/browse-category.json";
|
|
4
|
+
import categoriesFixture from "../__fixtures__/categories.json";
|
|
5
|
+
import checkoutFixture from "../__fixtures__/checkout.json";
|
|
6
|
+
import productFixture from "../__fixtures__/product.json";
|
|
7
|
+
import searchFixture from "../__fixtures__/search.json";
|
|
8
|
+
import shippingFixture from "../__fixtures__/shipping-options.json";
|
|
9
|
+
import createCheckout from "../actions/createCheckout";
|
|
10
|
+
import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../client";
|
|
11
|
+
import categories from "../loaders/categories";
|
|
12
|
+
import productDetailsPage from "../loaders/productDetailsPage";
|
|
13
|
+
import productList from "../loaders/productList";
|
|
14
|
+
import productListingPage from "../loaders/productListingPage";
|
|
15
|
+
import relatedProducts from "../loaders/relatedProducts";
|
|
16
|
+
import shippingOptions from "../loaders/shippingOptions";
|
|
17
|
+
import suggestions from "../loaders/suggestions";
|
|
18
|
+
|
|
19
|
+
const SITE = "https://loja.example";
|
|
20
|
+
const notFound = { error: { code: "resource_not_found", message: "not found" } };
|
|
21
|
+
const camisas = categoriesFixture.data.find((c) => c.handle === "camisas")!;
|
|
22
|
+
|
|
23
|
+
const json = (body: unknown, status = 200) =>
|
|
24
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
25
|
+
|
|
26
|
+
// Routes requests to the captured fixtures, like the real API would.
|
|
27
|
+
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
28
|
+
const url = new URL(String(input));
|
|
29
|
+
const path = url.pathname.replace(/^\/v2026-11\/stores\/8336778/, "");
|
|
30
|
+
if (init?.method === "POST") {
|
|
31
|
+
if (path === "/checkouts") return json(checkoutFixture);
|
|
32
|
+
if (path === "/shipping-options") return json(shippingFixture);
|
|
33
|
+
}
|
|
34
|
+
if (path === "/search/products") return json(searchFixture);
|
|
35
|
+
if (path === "/products") return json(browseFixture);
|
|
36
|
+
if (path === "/products/camisa-xadrez-lenhador-azul") return json(productFixture);
|
|
37
|
+
if (path === "/categories") return json(categoriesFixture);
|
|
38
|
+
if (path === "/categories/camisas" || path === `/categories/${camisas.id}`) return json(camisas);
|
|
39
|
+
return json(notFound, 404);
|
|
40
|
+
});
|
|
41
|
+
const lastUrl = (pathEnd: string) =>
|
|
42
|
+
new URL(
|
|
43
|
+
String(
|
|
44
|
+
fetchMock.mock.calls
|
|
45
|
+
.map((c) => c[0])
|
|
46
|
+
.reverse()
|
|
47
|
+
.find((u) => new URL(String(u)).pathname.endsWith(pathEnd)),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
fetchMock.mockClear();
|
|
53
|
+
clearNuvemshopCache();
|
|
54
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
55
|
+
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("productListingPage", () => {
|
|
59
|
+
it("resolves the category from the URL path and returns a PLP", async () => {
|
|
60
|
+
const plp = await productListingPage({
|
|
61
|
+
__pageUrl: `${SITE}/camisas/?Cor=Cinza`,
|
|
62
|
+
__pagePath: "/camisas/",
|
|
63
|
+
});
|
|
64
|
+
expectTypeOf(plp).toEqualTypeOf<ProductListingPage | null>();
|
|
65
|
+
expect(lastUrl("/products").searchParams.get("category_id")).toBe(String(camisas.id));
|
|
66
|
+
expect(lastUrl("/products").searchParams.get("per_page")).toBe("200");
|
|
67
|
+
expect(plp!.products).toHaveLength(1);
|
|
68
|
+
expect(plp!.breadcrumb.itemListElement).toEqual([
|
|
69
|
+
{ "@type": "ListItem", name: "Camisas", item: `${SITE}/camisas`, position: 1 },
|
|
70
|
+
]);
|
|
71
|
+
expect(plp!.pageInfo.pageTypes).toEqual(["Category"]);
|
|
72
|
+
expect(plp!.seo).toMatchObject({ title: "Camisas", canonical: `${SITE}/camisas` });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("asks the API for best-selling order (the only sort it must do itself)", async () => {
|
|
76
|
+
await productListingPage({ __pageUrl: `${SITE}/camisas/?sort_by=best-selling` });
|
|
77
|
+
expect(lastUrl("/products").searchParams.get("sort_by")).toBe("best-selling");
|
|
78
|
+
await productListingPage({ __pageUrl: `${SITE}/camisas/?sort_by=price-ascending` });
|
|
79
|
+
expect(lastUrl("/products").searchParams.has("sort_by")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("searches when there's a term", async () => {
|
|
83
|
+
const plp = await productListingPage({ __pageUrl: `${SITE}/search/?q=vestido` });
|
|
84
|
+
expect(lastUrl("/search/products").searchParams.get("q")).toBe("vestido");
|
|
85
|
+
expect(plp!.products).toHaveLength(searchFixture.data.length);
|
|
86
|
+
expect(plp!.pageInfo.pageTypes).toEqual(["Search"]);
|
|
87
|
+
// /search/products takes no sort_by, so best-selling can't be honoured there
|
|
88
|
+
expect(plp!.sortOptions.map((o) => o.value)).not.toContain("best-selling");
|
|
89
|
+
expect(plp!.sortOptions[0]).toEqual({ value: "relevance", label: "Relevância" });
|
|
90
|
+
expect(lastUrl("/search/products").searchParams.has("sort_by")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("accepts a CMS-pinned categoryId and returns null for unknown categories", async () => {
|
|
94
|
+
expect(
|
|
95
|
+
await productListingPage({ categoryId: camisas.id, __pageUrl: `${SITE}/qualquer/` }),
|
|
96
|
+
).not.toBeNull();
|
|
97
|
+
expect(await productListingPage({ __pageUrl: `${SITE}/nao-existe/` })).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("productDetailsPage", () => {
|
|
102
|
+
it("loads by slug and honours ?variant=", async () => {
|
|
103
|
+
const pdp = await productDetailsPage({
|
|
104
|
+
slug: "camisa-xadrez-lenhador-azul",
|
|
105
|
+
__pageUrl: `${SITE}/produtos/camisa-xadrez-lenhador-azul/?variant=1612648511`,
|
|
106
|
+
});
|
|
107
|
+
expect(pdp!.product.productID).toBe("1612648511");
|
|
108
|
+
expect(pdp!.product.url).toBe(
|
|
109
|
+
`${SITE}/produtos/camisa-xadrez-lenhador-azul?variant=1612648511`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("derives the slug from a /produtos/<handle>/ path and 404s cleanly", async () => {
|
|
114
|
+
const pdp = await productDetailsPage({
|
|
115
|
+
__pageUrl: `${SITE}/produtos/camisa-xadrez-lenhador-azul/`,
|
|
116
|
+
});
|
|
117
|
+
expect(pdp!.product.isVariantOf!.productGroupID).toBe("372190453");
|
|
118
|
+
expect(await productDetailsPage({ slug: "nope" })).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("productList", () => {
|
|
123
|
+
it("lists by category with an API sort and count", async () => {
|
|
124
|
+
const list = await productList({ categoryId: camisas.id, sort: "price-ascending", count: 2 });
|
|
125
|
+
const url = lastUrl("/products");
|
|
126
|
+
expect(url.searchParams.get("sort_by")).toBe("price-ascending");
|
|
127
|
+
expect(url.searchParams.get("per_page")).toBe("2");
|
|
128
|
+
expect(list.length).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("lists by ids/handles without sort_by (the API rejects the combination)", async () => {
|
|
132
|
+
await productList({ ids: [372190453, 372190471], sort: "best-selling" });
|
|
133
|
+
const url = lastUrl("/products");
|
|
134
|
+
expect(url.searchParams.get("ids")).toBe("372190453,372190471");
|
|
135
|
+
expect(url.searchParams.has("sort_by")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("searches by term", async () => {
|
|
139
|
+
await productList({ query: "vestido", count: 4 });
|
|
140
|
+
expect(lastUrl("/search/products").searchParams.get("q")).toBe("vestido");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("relatedProducts", () => {
|
|
145
|
+
it("follows the theme: same category, without the product itself, in-stock first", async () => {
|
|
146
|
+
const outOfStock = {
|
|
147
|
+
...browseFixture.data[1],
|
|
148
|
+
variants: browseFixture.data[1].variants.map((v) => ({ ...v, stock: 0 })),
|
|
149
|
+
};
|
|
150
|
+
fetchMock
|
|
151
|
+
.mockImplementationOnce(async () => json(productFixture))
|
|
152
|
+
.mockImplementationOnce(async () =>
|
|
153
|
+
json({
|
|
154
|
+
...browseFixture,
|
|
155
|
+
data: [browseFixture.data[0], outOfStock, browseFixture.data[2]],
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
const related = await relatedProducts({ slug: "camisa-xadrez-lenhador-azul" });
|
|
159
|
+
expect(lastUrl("/products").searchParams.get("category_id")).toBe(String(camisas.id));
|
|
160
|
+
expect(related.map((p) => p.isVariantOf!.productGroupID)).toEqual([
|
|
161
|
+
String(browseFixture.data[2].id), // in stock
|
|
162
|
+
String(outOfStock.id), // refill with out-of-stock
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("caps at count and returns [] for an unknown product", async () => {
|
|
167
|
+
expect(await relatedProducts({ slug: "camisa-xadrez-lenhador-azul", count: 1 })).toHaveLength(
|
|
168
|
+
1,
|
|
169
|
+
);
|
|
170
|
+
expect(await relatedProducts({ slug: "nope" })).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("suggestions", () => {
|
|
175
|
+
it("returns products for the term and nothing for a blank one", async () => {
|
|
176
|
+
const s = await suggestions({ query: "vestido", count: 2 });
|
|
177
|
+
expect(s!.products).toHaveLength(2);
|
|
178
|
+
expect(await suggestions({ query: " " })).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("categories", () => {
|
|
183
|
+
it("builds a navigation tree with nested URLs", async () => {
|
|
184
|
+
const tree = await categories({}, new Request(`${SITE}/`));
|
|
185
|
+
const calcados = tree.find((c) => c.name === "Calçados")!;
|
|
186
|
+
expect(calcados.url).toBe(`${SITE}/calcados`);
|
|
187
|
+
expect(calcados.children!.map((c) => c.url)).toEqual([
|
|
188
|
+
`${SITE}/calcados/masculino`,
|
|
189
|
+
`${SITE}/calcados/feminino`,
|
|
190
|
+
]);
|
|
191
|
+
expect(tree.some((c) => c.name === "Masculino")).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("shippingOptions", () => {
|
|
196
|
+
it("quotes one variant for a CEP with numeric prices", async () => {
|
|
197
|
+
const options = await shippingOptions({ zipCode: "01310-100", variantId: 1612648502 });
|
|
198
|
+
expect(options[0]).toMatchObject({ code: "table_default_123123", price: 0 });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("createCheckout", () => {
|
|
203
|
+
it("maps cart items to line_items and returns the hosted checkout URL", async () => {
|
|
204
|
+
const res = await createCheckout({
|
|
205
|
+
items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }],
|
|
206
|
+
coupon: " TEST ",
|
|
207
|
+
});
|
|
208
|
+
expect(res.checkoutUrl).toBe(checkoutFixture.checkout_url);
|
|
209
|
+
const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body));
|
|
210
|
+
expect(body).toEqual({
|
|
211
|
+
line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }],
|
|
212
|
+
coupon_code: "TEST",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects an empty cart without calling the API", async () => {
|
|
217
|
+
await expect(createCheckout({ items: [] })).rejects.toThrow(/empty/);
|
|
218
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getNuvemshopConfig } from "../client";
|
|
3
|
+
import { configure } from "../mod";
|
|
4
|
+
import { nuvemshopOperationRouter } from "../utils/operationRouter";
|
|
5
|
+
|
|
6
|
+
const noSecret = async () => null;
|
|
7
|
+
|
|
8
|
+
describe("configure", () => {
|
|
9
|
+
it("returns null without a numeric storeId", async () => {
|
|
10
|
+
expect(await configure({}, noSecret)).toBeNull();
|
|
11
|
+
expect(await configure({ storeId: "loja" }, noSecret)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("works tokenless and registers every manifest handler as a function", async () => {
|
|
15
|
+
const app = await configure({ storeId: 8336778 }, noSecret);
|
|
16
|
+
expect(app!.name).toBe("nuvemshop");
|
|
17
|
+
expect(getNuvemshopConfig()).toMatchObject({ storeId: "8336778", token: undefined });
|
|
18
|
+
for (const mod of [
|
|
19
|
+
...Object.values(app!.manifest.loaders),
|
|
20
|
+
...Object.values(app!.manifest.actions!),
|
|
21
|
+
]) {
|
|
22
|
+
expect(typeof (mod as { default: unknown }).default).toBe("function");
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves the token through the secret resolver (env fallback key)", async () => {
|
|
27
|
+
let envKey = "";
|
|
28
|
+
await configure({ storeId: "1", token: { encrypted: "x" } }, async (_v, key) => {
|
|
29
|
+
envKey = key;
|
|
30
|
+
return "tok";
|
|
31
|
+
});
|
|
32
|
+
expect(envKey).toBe("NUVEMSHOP_STOREFRONT_TOKEN");
|
|
33
|
+
expect(getNuvemshopConfig().token).toBe("tok");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("nuvemshopOperationRouter", () => {
|
|
38
|
+
const base = "https://storefront-api.tiendanube.com/v2026-11/stores/1";
|
|
39
|
+
it.each([
|
|
40
|
+
[`${base}/products?fields=id`, "products.browse"],
|
|
41
|
+
[`${base}/products/camisa`, "products.get"],
|
|
42
|
+
[`${base}/search/products?q=x`, "products.search"],
|
|
43
|
+
[`${base}/categories`, "categories.list"],
|
|
44
|
+
[`${base}/categories/calcados`, "categories.get"],
|
|
45
|
+
[`${base}/shipping-options`, "shipping.options"],
|
|
46
|
+
[`${base}/checkouts`, "checkout.create"],
|
|
47
|
+
["https://example.com/other", undefined],
|
|
48
|
+
])("%s → %s", (url, op) => {
|
|
49
|
+
expect(nuvemshopOperationRouter(url, "GET")).toBe(op);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { nuvemshopPost } from "../client";
|
|
2
|
+
import type { NuvemshopCheckout, NuvemshopLineItem } from "../utils/types";
|
|
3
|
+
|
|
4
|
+
export interface CartItem {
|
|
5
|
+
productId: number;
|
|
6
|
+
variantId: number;
|
|
7
|
+
quantity: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Props {
|
|
11
|
+
items: CartItem[];
|
|
12
|
+
coupon?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @title Nuvemshop - Create Checkout
|
|
17
|
+
* @description Opens a Nuvemshop cart with the items (and coupon) and returns the hosted checkout URL.
|
|
18
|
+
*/
|
|
19
|
+
export default async function createCheckout(
|
|
20
|
+
props: Props,
|
|
21
|
+
req?: Request,
|
|
22
|
+
): Promise<{ checkoutUrl: string }> {
|
|
23
|
+
if (!props.items?.length) throw new Error("Cannot checkout an empty cart");
|
|
24
|
+
const line_items: NuvemshopLineItem[] = props.items.map((i) => ({
|
|
25
|
+
product_id: i.productId,
|
|
26
|
+
variant_id: i.variantId,
|
|
27
|
+
quantity: i.quantity,
|
|
28
|
+
}));
|
|
29
|
+
const coupon_code = props.coupon?.trim() || undefined;
|
|
30
|
+
const res = await nuvemshopPost<NuvemshopCheckout>(
|
|
31
|
+
"/checkouts",
|
|
32
|
+
{ line_items, coupon_code },
|
|
33
|
+
{ buyerIp: req?.headers.get("cf-connecting-ip") },
|
|
34
|
+
);
|
|
35
|
+
return { checkoutUrl: res.checkout_url };
|
|
36
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuvemshop (Tiendanube) Storefront API client.
|
|
3
|
+
*
|
|
4
|
+
* REST over `https://storefront-api.tiendanube.com/<version>/stores/<id>`.
|
|
5
|
+
* Access is tokenless (120 req/min per store + client IP) or with a
|
|
6
|
+
* store-scoped bearer token (1,200 req/min per store). From a Worker every
|
|
7
|
+
* buyer shares the egress IP, so production stores should configure a token.
|
|
8
|
+
*
|
|
9
|
+
* - GETs go through the shared SWR cache (`createFetchCache`) keyed by URL.
|
|
10
|
+
* - POSTs forward the buyer IP (`X-LinkedStore-Buyer-IP`) when a token is
|
|
11
|
+
* set, giving each buyer their own rate-limit bucket. Tokenless requests
|
|
12
|
+
* ignore that header, so it's only sent with a token.
|
|
13
|
+
* - Transport defaults to the instrumented `createNuvemshopFetch()`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createFetchCache } from "@decocms/blocks/sdk/fetchCache";
|
|
17
|
+
import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
|
|
18
|
+
import { createNuvemshopFetch } from "./utils/instrumentedFetch";
|
|
19
|
+
|
|
20
|
+
export interface NuvemshopConfig {
|
|
21
|
+
storeId: string;
|
|
22
|
+
/** Store-scoped Storefront token. Optional; recommended in production. */
|
|
23
|
+
token?: string;
|
|
24
|
+
/** Default "v2026-11". */
|
|
25
|
+
apiVersion?: string;
|
|
26
|
+
/** Default "https://storefront-api.tiendanube.com". */
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
/** Currency of the store's prices (the API doesn't return one). Default "BRL". */
|
|
29
|
+
currency?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Category default sort (a theme `sort_by` value). The API doesn't expose a
|
|
32
|
+
* category's configured sort and returns oldest-first. Default "created-descending".
|
|
33
|
+
*/
|
|
34
|
+
defaultSort?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Root fields requested for every product. The API's default selection is
|
|
39
|
+
* only `id,name,handle`. `cost` is deliberately absent.
|
|
40
|
+
*/
|
|
41
|
+
export const PRODUCT_FIELDS = [
|
|
42
|
+
"id",
|
|
43
|
+
"name",
|
|
44
|
+
"handle",
|
|
45
|
+
"description",
|
|
46
|
+
"images",
|
|
47
|
+
"variants",
|
|
48
|
+
"brand",
|
|
49
|
+
"tags",
|
|
50
|
+
"categories",
|
|
51
|
+
"attributes",
|
|
52
|
+
"seo_title",
|
|
53
|
+
"seo_description",
|
|
54
|
+
"free_shipping",
|
|
55
|
+
"video_url",
|
|
56
|
+
"created_at",
|
|
57
|
+
].join(",");
|
|
58
|
+
|
|
59
|
+
/** The default category selection also lacks `parent`, which breaks trees and breadcrumbs. */
|
|
60
|
+
export const CATEGORY_FIELDS =
|
|
61
|
+
"id,name,handle,parent,subcategories,description,seo_title,seo_description";
|
|
62
|
+
|
|
63
|
+
const DEFAULT_FIELDS: [RegExp, string][] = [
|
|
64
|
+
[/^\/(search\/)?products(\/|$)/, PRODUCT_FIELDS],
|
|
65
|
+
[/^\/categories(\/|$)/, CATEGORY_FIELDS],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
let _config: NuvemshopConfig | null = null;
|
|
69
|
+
let _fetch: FetchFn | undefined;
|
|
70
|
+
|
|
71
|
+
const cache = createFetchCache({
|
|
72
|
+
provider: "nuvemshop",
|
|
73
|
+
maxEntries: 500,
|
|
74
|
+
// Catalog doesn't need to be second-fresh; a just-published product shouldn't 404 long.
|
|
75
|
+
freshTtlMs: { success: 120_000, notFound: 10_000, serverError: 0 },
|
|
76
|
+
staleIfErrorMs: 86_400_000,
|
|
77
|
+
inflightBackstopMs: 15_000,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export function configureNuvemshop(config: NuvemshopConfig | null) {
|
|
81
|
+
_config = config;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getNuvemshopConfig(): NuvemshopConfig {
|
|
85
|
+
if (!_config)
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Nuvemshop is not configured — add a deco-nuvemshop block or call configureNuvemshop()",
|
|
88
|
+
);
|
|
89
|
+
return _config;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Override the transport (tests, or a site wrapping its own fetch). */
|
|
93
|
+
export function setNuvemshopFetch(fn: FetchFn) {
|
|
94
|
+
_fetch = fn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function clearNuvemshopCache() {
|
|
98
|
+
cache.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const transport = () => (_fetch ??= createNuvemshopFetch() as FetchFn);
|
|
102
|
+
|
|
103
|
+
function storeUrl(path: string, params: Record<string, string | number | undefined> = {}) {
|
|
104
|
+
const {
|
|
105
|
+
storeId,
|
|
106
|
+
apiVersion = "v2026-11",
|
|
107
|
+
baseUrl = "https://storefront-api.tiendanube.com",
|
|
108
|
+
} = getNuvemshopConfig();
|
|
109
|
+
const url = new URL(`${baseUrl}/${apiVersion}/stores/${storeId}${path}`);
|
|
110
|
+
const fields = DEFAULT_FIELDS.find(([route]) => route.test(path))?.[1];
|
|
111
|
+
if (fields && params.fields === undefined) params = { ...params, fields };
|
|
112
|
+
for (const [k, v] of Object.entries(params))
|
|
113
|
+
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
|
114
|
+
return url.toString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function headers(extra?: Record<string, string>) {
|
|
118
|
+
const { token } = getNuvemshopConfig();
|
|
119
|
+
return {
|
|
120
|
+
accept: "application/json",
|
|
121
|
+
...(token && { authorization: `Bearer ${token}` }),
|
|
122
|
+
...extra,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function errorFrom(res: Response) {
|
|
127
|
+
const body = (await res.json().catch(() => null)) as {
|
|
128
|
+
error?: { code?: string; message?: string };
|
|
129
|
+
} | null;
|
|
130
|
+
return new Error(
|
|
131
|
+
`Nuvemshop ${res.status} ${body?.error?.code ?? ""}: ${body?.error?.message ?? res.statusText}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Cached GET. Resolves `null` on 404 (and other cacheable non-2xx). */
|
|
136
|
+
export function nuvemshopGet<T>(
|
|
137
|
+
path: string,
|
|
138
|
+
params?: Record<string, string | number | undefined>,
|
|
139
|
+
): Promise<T | null> {
|
|
140
|
+
try {
|
|
141
|
+
const url = storeUrl(path, params);
|
|
142
|
+
return cache.fetchWithCache<T>(url, () => transport()(url, { headers: headers() }));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return Promise.reject(e);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function nuvemshopPost<T>(
|
|
149
|
+
path: string,
|
|
150
|
+
body: unknown,
|
|
151
|
+
opts: { buyerIp?: string | null } = {},
|
|
152
|
+
): Promise<T> {
|
|
153
|
+
const url = storeUrl(path);
|
|
154
|
+
const { token } = getNuvemshopConfig();
|
|
155
|
+
const res = await transport()(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: headers({
|
|
158
|
+
"content-type": "application/json",
|
|
159
|
+
...(token && opts.buyerIp && { "x-linkedstore-buyer-ip": opts.buyerIp }),
|
|
160
|
+
}),
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) throw await errorFrom(res);
|
|
164
|
+
return res.json() as Promise<T>;
|
|
165
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
addItem,
|
|
4
|
+
CART_STORAGE_KEY,
|
|
5
|
+
cartStore,
|
|
6
|
+
checkout,
|
|
7
|
+
removeItem,
|
|
8
|
+
setQuantity,
|
|
9
|
+
} from "../useCart";
|
|
10
|
+
|
|
11
|
+
const shirt = { productId: 1, variantId: 10, quantity: 1 };
|
|
12
|
+
const pants = { productId: 2, variantId: 20, quantity: 1 };
|
|
13
|
+
|
|
14
|
+
describe("cart reducers", () => {
|
|
15
|
+
it("adds, merges quantities and caps at 99 (API limit per variant)", () => {
|
|
16
|
+
expect(addItem([], shirt)).toEqual([shirt]);
|
|
17
|
+
expect(addItem([shirt], { ...shirt, quantity: 2 })).toEqual([{ ...shirt, quantity: 3 }]);
|
|
18
|
+
expect(addItem([shirt], { ...shirt, quantity: 500 })[0].quantity).toBe(99);
|
|
19
|
+
});
|
|
20
|
+
it("sets quantity and drops the line at 0", () => {
|
|
21
|
+
expect(setQuantity([shirt, pants], 20, 4)).toEqual([shirt, { ...pants, quantity: 4 }]);
|
|
22
|
+
expect(setQuantity([shirt, pants], 10, 0)).toEqual([pants]);
|
|
23
|
+
expect(removeItem([shirt, pants], 10)).toEqual([pants]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("cartStore", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
localStorage.clear();
|
|
30
|
+
cartStore.set([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("persists to localStorage and notifies subscribers", () => {
|
|
34
|
+
const seen = vi.fn();
|
|
35
|
+
const off = cartStore.subscribe(seen);
|
|
36
|
+
cartStore.update((items) => addItem(items, shirt));
|
|
37
|
+
expect(cartStore.get()).toEqual([shirt]);
|
|
38
|
+
expect(JSON.parse(localStorage.getItem(CART_STORAGE_KEY)!)).toEqual([shirt]);
|
|
39
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
40
|
+
off();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("picks up writes from other tabs", () => {
|
|
44
|
+
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify([pants]));
|
|
45
|
+
window.dispatchEvent(new StorageEvent("storage", { key: CART_STORAGE_KEY }));
|
|
46
|
+
expect(cartStore.get()).toEqual([pants]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("survives corrupt storage", () => {
|
|
50
|
+
localStorage.setItem(CART_STORAGE_KEY, "{not json");
|
|
51
|
+
window.dispatchEvent(new StorageEvent("storage", { key: CART_STORAGE_KEY }));
|
|
52
|
+
expect(cartStore.get()).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("checkout", () => {
|
|
57
|
+
it("invokes createCheckout and navigates to the hosted checkout", async () => {
|
|
58
|
+
const fetchSpy = vi
|
|
59
|
+
.spyOn(globalThis, "fetch")
|
|
60
|
+
.mockResolvedValue(
|
|
61
|
+
new Response(JSON.stringify({ checkoutUrl: "https://checkout.example/1" })),
|
|
62
|
+
);
|
|
63
|
+
const navigate = vi.fn();
|
|
64
|
+
await checkout([shirt], "PROMO", navigate);
|
|
65
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
66
|
+
expect(url).toBe("/deco/invoke/nuvemshop/actions/createCheckout");
|
|
67
|
+
expect(JSON.parse(String(init!.body))).toEqual({ items: [shirt], coupon: "PROMO" });
|
|
68
|
+
expect(navigate).toHaveBeenCalledWith("https://checkout.example/1");
|
|
69
|
+
fetchSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("throws the server error instead of navigating", async () => {
|
|
73
|
+
const fetchSpy = vi
|
|
74
|
+
.spyOn(globalThis, "fetch")
|
|
75
|
+
.mockResolvedValue(new Response("boom", { status: 500 }));
|
|
76
|
+
const navigate = vi.fn();
|
|
77
|
+
await expect(checkout([shirt], undefined, navigate)).rejects.toThrow(/500/);
|
|
78
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
79
|
+
fetchSpy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
});
|