@decocms/apps-nuvemshop 7.75.0 → 7.77.0
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 +4 -4
- package/src/__tests__/account.test.ts +250 -0
- package/src/__tests__/cart.test.ts +118 -0
- package/src/__tests__/loaders.test.ts +22 -3
- package/src/__tests__/mod.test.ts +38 -4
- package/src/actions/account/login.ts +30 -0
- package/src/actions/account/logout.ts +10 -0
- package/src/actions/account/register.ts +83 -0
- package/src/actions/createCheckout.ts +37 -9
- package/src/admin.ts +23 -0
- package/src/client.ts +34 -2
- package/src/hooks/__tests__/useCart.test.ts +37 -0
- package/src/hooks/useCart.ts +51 -5
- package/src/index.ts +7 -0
- package/src/loaders/cart.ts +108 -0
- package/src/loaders/user.ts +30 -0
- package/src/manifest.gen.ts +10 -0
- package/src/mod.ts +31 -0
- package/src/store.ts +88 -0
- package/src/utils/__tests__/sitemap.test.ts +57 -0
- package/src/utils/operationRouter.ts +7 -1
- package/src/utils/sitemap.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-nuvemshop",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.77.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce app: Nuvemshop (Tiendanube) Storefront API integration",
|
|
6
6
|
"repository": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"lint:unused": "knip"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@decocms/blocks": "7.
|
|
34
|
-
"@decocms/apps-commerce": "7.
|
|
35
|
-
"@decocms/apps-website": "7.
|
|
33
|
+
"@decocms/blocks": "7.77.0",
|
|
34
|
+
"@decocms/apps-commerce": "7.77.0",
|
|
35
|
+
"@decocms/apps-website": "7.77.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^19.0.0"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import login from "../actions/account/login";
|
|
4
|
+
import logout from "../actions/account/logout";
|
|
5
|
+
import register from "../actions/account/register";
|
|
6
|
+
import { configureNuvemshop, setNuvemshopFetch } from "../client";
|
|
7
|
+
import user from "../loaders/user";
|
|
8
|
+
|
|
9
|
+
const STORE = "https://demodeco.lojavirtualnuvem.com.br";
|
|
10
|
+
const SITE = "https://www.loja.example";
|
|
11
|
+
const ADMIN = "https://api.nuvemshop.com.br/v1/8336778";
|
|
12
|
+
|
|
13
|
+
type Route = (url: URL, init: RequestInit) => Response | Promise<Response>;
|
|
14
|
+
let routes: Record<string, Route> = {};
|
|
15
|
+
const fetchMock = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
16
|
+
const url = new URL(String(input));
|
|
17
|
+
const key = `${init.method ?? "GET"} ${url.origin}${url.pathname}`;
|
|
18
|
+
const route = routes[key];
|
|
19
|
+
if (!route) throw new Error(`unexpected ${key}`);
|
|
20
|
+
return route(url, init);
|
|
21
|
+
});
|
|
22
|
+
const calls = (key: string) =>
|
|
23
|
+
fetchMock.mock.calls.filter(
|
|
24
|
+
([u, i]) =>
|
|
25
|
+
`${i?.method ?? "GET"} ${new URL(String(u)).origin}${new URL(String(u)).pathname}` === key,
|
|
26
|
+
);
|
|
27
|
+
const html = (body: string, init: ResponseInit = {}) =>
|
|
28
|
+
new Response(body, { ...init, headers: { "content-type": "text/html", ...init.headers } });
|
|
29
|
+
const redirect = (location: string, cookies: string[] = []) => {
|
|
30
|
+
const headers = new Headers({ location });
|
|
31
|
+
for (const c of cookies) headers.append("set-cookie", c);
|
|
32
|
+
return new Response(null, { status: 302, headers });
|
|
33
|
+
};
|
|
34
|
+
const SESSION_SET = `store_session_payload_8336778=NEW; expires=Thu, 05-Nov-2026 23:56:42 GMT; path=/; domain=demodeco.lojavirtualnuvem.com.br; secure; HttpOnly; SameSite=Lax`;
|
|
35
|
+
const inRequest = <T>(fn: () => Promise<T>, cookie = "") =>
|
|
36
|
+
RequestContext.run(new Request(`${SITE}/deco/invoke/x`, { headers: { cookie } }), async () => {
|
|
37
|
+
const result = await fn();
|
|
38
|
+
return { result, setCookies: RequestContext.current!.responseHeaders.getSetCookie() };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
fetchMock.mockClear();
|
|
43
|
+
routes = {};
|
|
44
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE, adminToken: "admintoken" });
|
|
45
|
+
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("login", () => {
|
|
49
|
+
it("posts the classic form and forwards the store session cookie onto our domain", async () => {
|
|
50
|
+
const CF =
|
|
51
|
+
"__cf_bm=abc; HttpOnly; SameSite=None; Secure; Path=/; Domain=lojavirtualnuvem.com.br";
|
|
52
|
+
routes[`POST ${STORE}/account/login/`] = () => redirect(`${STORE}/account/`, [SESSION_SET, CF]);
|
|
53
|
+
const { result, setCookies } = await inRequest(
|
|
54
|
+
() => login({ email: "a@b.com", password: "pw" }),
|
|
55
|
+
"store_session_payload_8336778=OLD; _ga=GA1.1; other=x",
|
|
56
|
+
);
|
|
57
|
+
expect(result).toEqual({ ok: true });
|
|
58
|
+
|
|
59
|
+
const [, init] = calls(`POST ${STORE}/account/login/`)[0];
|
|
60
|
+
expect(String(init!.body)).toBe("email=a%40b.com&password=pw");
|
|
61
|
+
const headers = new Headers(init!.headers);
|
|
62
|
+
// only the store's own cookies go upstream — never the site's (_ga, other)
|
|
63
|
+
expect(headers.get("cookie")).toBe("store_session_payload_8336778=OLD");
|
|
64
|
+
expect(headers.get("origin")).toBe(STORE);
|
|
65
|
+
expect(init!.redirect).toBe("manual");
|
|
66
|
+
|
|
67
|
+
// only the store session is re-emitted on our domain (not the store's Cloudflare cookies)
|
|
68
|
+
expect(setCookies).toEqual([
|
|
69
|
+
SESSION_SET.replace("domain=demodeco.lojavirtualnuvem.com.br", "Domain=www.loja.example"),
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
[
|
|
75
|
+
'<div class="alert alert-danger js-login-general-error"> Esses dados estão incorretos.',
|
|
76
|
+
"invalid_credentials",
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'<div class="js-account-validation-pending alert alert-danger">Valide seu e-mail',
|
|
80
|
+
"email_not_validated",
|
|
81
|
+
],
|
|
82
|
+
["<form id=login-form></form>", "unknown"],
|
|
83
|
+
])("maps the login page marker to a reason (%#)", async (marker, error) => {
|
|
84
|
+
routes[`POST ${STORE}/account/login/`] = () =>
|
|
85
|
+
redirect(`${STORE}/account/login/`, [SESSION_SET]);
|
|
86
|
+
routes[`GET ${STORE}/account/login/`] = () => html(`<html>${marker}</html>`);
|
|
87
|
+
const { result } = await inRequest(() => login({ email: "a@b.com", password: "bad" }));
|
|
88
|
+
expect(result).toEqual({ ok: false, error });
|
|
89
|
+
// the follow-up page read reuses the session the POST just set
|
|
90
|
+
expect(new Headers(calls(`GET ${STORE}/account/login/`)[0][1]!.headers).get("cookie")).toBe(
|
|
91
|
+
"store_session_payload_8336778=NEW",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects missing credentials without calling the store", async () => {
|
|
96
|
+
const { result } = await inRequest(() => login({ email: "", password: "" }));
|
|
97
|
+
expect(result).toEqual({ ok: false, error: "invalid_credentials" });
|
|
98
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("requires storeUrl", async () => {
|
|
102
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
103
|
+
await expect(inRequest(() => login({ email: "a@b.com", password: "pw" }))).rejects.toThrow(
|
|
104
|
+
/storeUrl/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("logout", () => {
|
|
110
|
+
it("hits /account/logout/ with the session and forwards the expiring cookie", async () => {
|
|
111
|
+
const expired =
|
|
112
|
+
"store_session_payload_8336778=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; domain=demodeco.lojavirtualnuvem.com.br";
|
|
113
|
+
routes[`GET ${STORE}/account/logout/`] = () => redirect(`${STORE}/`, [expired]);
|
|
114
|
+
const { result, setCookies } = await inRequest(
|
|
115
|
+
() => logout({}),
|
|
116
|
+
"store_session_payload_8336778=S",
|
|
117
|
+
);
|
|
118
|
+
expect(result).toEqual({ ok: true });
|
|
119
|
+
expect(setCookies[0]).toContain("Domain=www.loja.example");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("register", () => {
|
|
124
|
+
const ok = () =>
|
|
125
|
+
new Response(JSON.stringify({ id: 350524152, email: "a@b.com", active: true }), {
|
|
126
|
+
status: 201,
|
|
127
|
+
});
|
|
128
|
+
const props = { name: "Ana", email: "a@b.com", password: "s3nha-forte", captchaToken: "tok" };
|
|
129
|
+
|
|
130
|
+
it("creates the customer through the Admin API (no reCAPTCHA) after verifying Turnstile", async () => {
|
|
131
|
+
configureNuvemshop({
|
|
132
|
+
storeId: "8336778",
|
|
133
|
+
storeUrl: STORE,
|
|
134
|
+
adminToken: "admintoken",
|
|
135
|
+
turnstileSecret: "ts",
|
|
136
|
+
});
|
|
137
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
138
|
+
Response.json({ success: true });
|
|
139
|
+
routes[`POST ${ADMIN}/customers`] = ok;
|
|
140
|
+
const { result } = await inRequest(() => register(props));
|
|
141
|
+
expect(result).toEqual({ ok: true, customerId: 350524152, emailValidationRequired: true });
|
|
142
|
+
|
|
143
|
+
const [, init] = calls(`POST ${ADMIN}/customers`)[0];
|
|
144
|
+
expect(new Headers(init!.headers).get("authentication")).toBe("bearer admintoken");
|
|
145
|
+
expect(new Headers(init!.headers).get("user-agent")).toMatch(/deco/);
|
|
146
|
+
expect(JSON.parse(String(init!.body))).toEqual({
|
|
147
|
+
name: "Ana",
|
|
148
|
+
email: "a@b.com",
|
|
149
|
+
password: "s3nha-forte",
|
|
150
|
+
send_email_invite: false,
|
|
151
|
+
});
|
|
152
|
+
const verify = new URLSearchParams(
|
|
153
|
+
String(calls("POST https://challenges.cloudflare.com/turnstile/v0/siteverify")[0][1]!.body),
|
|
154
|
+
);
|
|
155
|
+
expect(Object.fromEntries(verify)).toMatchObject({ secret: "ts", response: "tok" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("refuses when bot protection isn't configured (the Admin API has no captcha)", async () => {
|
|
159
|
+
const { result } = await inRequest(() => register(props));
|
|
160
|
+
expect(result).toEqual({ ok: false, error: "captcha_not_configured" });
|
|
161
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("allows an explicit opt-out for demos", async () => {
|
|
165
|
+
configureNuvemshop({
|
|
166
|
+
storeId: "8336778",
|
|
167
|
+
storeUrl: STORE,
|
|
168
|
+
adminToken: "admintoken",
|
|
169
|
+
allowUnverifiedRegistration: true,
|
|
170
|
+
});
|
|
171
|
+
routes[`POST ${ADMIN}/customers`] = ok;
|
|
172
|
+
expect(
|
|
173
|
+
(await inRequest(() => register({ ...props, captchaToken: undefined }))).result,
|
|
174
|
+
).toMatchObject({ ok: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("maps a failed captcha, a taken email and weak input", async () => {
|
|
178
|
+
configureNuvemshop({
|
|
179
|
+
storeId: "8336778",
|
|
180
|
+
storeUrl: STORE,
|
|
181
|
+
adminToken: "admintoken",
|
|
182
|
+
turnstileSecret: "ts",
|
|
183
|
+
});
|
|
184
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
185
|
+
Response.json({ success: false });
|
|
186
|
+
expect((await inRequest(() => register(props))).result).toEqual({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: "captcha_failed",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
192
|
+
Response.json({ success: true });
|
|
193
|
+
routes[`POST ${ADMIN}/customers`] = () =>
|
|
194
|
+
Response.json({ email: ["The email must be unique."] }, { status: 422 });
|
|
195
|
+
expect((await inRequest(() => register(props))).result).toEqual({
|
|
196
|
+
ok: false,
|
|
197
|
+
error: "email_taken",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect((await inRequest(() => register({ ...props, email: "nope" }))).result).toEqual({
|
|
201
|
+
ok: false,
|
|
202
|
+
error: "invalid_input",
|
|
203
|
+
});
|
|
204
|
+
expect((await inRequest(() => register({ ...props, password: "123" }))).result).toEqual({
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "invalid_input",
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("requires the admin token", async () => {
|
|
211
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE, allowUnverifiedRegistration: true });
|
|
212
|
+
await expect(inRequest(() => register(props))).rejects.toThrow(/adminToken/);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("user", () => {
|
|
217
|
+
it("returns null without a store session and never calls upstream", async () => {
|
|
218
|
+
const { result } = await inRequest(() => user({}), "_ga=1");
|
|
219
|
+
expect(result).toBeNull();
|
|
220
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null when the store redirects to login", async () => {
|
|
224
|
+
routes[`GET ${STORE}/account/`] = () => redirect(`${STORE}/account/login/`);
|
|
225
|
+
expect((await inRequest(() => user({}), "store_session_payload_8336778=S")).result).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("reads the logged-in customer id from the account page and its data from the Admin API", async () => {
|
|
229
|
+
routes[`GET ${STORE}/account/`] = () => html("<script>LS.customer = 350524152;</script>");
|
|
230
|
+
routes[`GET ${ADMIN}/customers/350524152`] = () =>
|
|
231
|
+
Response.json({
|
|
232
|
+
id: 350524152,
|
|
233
|
+
name: "Ana",
|
|
234
|
+
email: "a@b.com",
|
|
235
|
+
phone: "11999990000",
|
|
236
|
+
total_spent: "10.00",
|
|
237
|
+
addresses: [],
|
|
238
|
+
});
|
|
239
|
+
const { result } = await inRequest(() => user({}), "store_session_payload_8336778=S");
|
|
240
|
+
expect(result).toEqual({ id: 350524152, name: "Ana", email: "a@b.com", phone: "11999990000" });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("falls back to just the id without an admin token", async () => {
|
|
244
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE });
|
|
245
|
+
routes[`GET ${STORE}/account/`] = () => html("<script>LS.customer = 7;</script>");
|
|
246
|
+
expect((await inRequest(() => user({}), "store_session_payload_8336778=S")).result).toEqual({
|
|
247
|
+
id: 7,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import browseFixture from "../__fixtures__/browse-category.json";
|
|
3
|
+
import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../client";
|
|
4
|
+
import cart from "../loaders/cart";
|
|
5
|
+
|
|
6
|
+
// browse fixture: [0] camisa-xadrez 189.90 (stock 17,20,…), [1] camisa-flanela 169.90 promo / 199.90, [2] camiseta-gamer 89.90
|
|
7
|
+
const [xadrez, flanela, gamer] = browseFixture.data;
|
|
8
|
+
const SITE = "https://www.loja.example";
|
|
9
|
+
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
|
10
|
+
const url = new URL(String(input));
|
|
11
|
+
const ids = (url.searchParams.get("ids") ?? "").split(",").map(Number);
|
|
12
|
+
const data = browseFixture.data.filter((p) => ids.includes(p.id));
|
|
13
|
+
return new Response(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
data,
|
|
16
|
+
pagination: { page: 1, per_page: 30, total: data.length, total_pages: 1 },
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
const item = (p: (typeof browseFixture.data)[number], v = 0, quantity = 1) => ({
|
|
21
|
+
productId: p.id,
|
|
22
|
+
variantId: p.variants[v].id,
|
|
23
|
+
quantity,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fetchMock.mockClear();
|
|
28
|
+
clearNuvemshopCache();
|
|
29
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
30
|
+
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("cart loader", () => {
|
|
34
|
+
it("resolves each line from the Storefront API with prices, image and variant", async () => {
|
|
35
|
+
const c = await cart({ items: [item(xadrez, 0, 2), item(flanela, 5)], __pageUrl: `${SITE}/` });
|
|
36
|
+
expect(c.lines).toHaveLength(2);
|
|
37
|
+
expect(c.lines[0]).toMatchObject({
|
|
38
|
+
productId: xadrez.id,
|
|
39
|
+
variantId: xadrez.variants[0].id,
|
|
40
|
+
name: "Camisa Xadrez Lenhador Azul",
|
|
41
|
+
variantName: "P / Azul e Preto",
|
|
42
|
+
quantity: 2,
|
|
43
|
+
maxQuantity: 17,
|
|
44
|
+
price: 189.9,
|
|
45
|
+
listPrice: 189.9,
|
|
46
|
+
subtotal: 379.8,
|
|
47
|
+
url: `${SITE}/produtos/camisa-xadrez-lenhador-azul?variant=${xadrez.variants[0].id}`,
|
|
48
|
+
});
|
|
49
|
+
expect(c.lines[1]).toMatchObject({
|
|
50
|
+
variantName: "M / Cinza",
|
|
51
|
+
price: 169.9,
|
|
52
|
+
listPrice: 199.9,
|
|
53
|
+
subtotal: 169.9,
|
|
54
|
+
});
|
|
55
|
+
expect(c.lines[1].image).toBe(
|
|
56
|
+
flanela.images.find((i) => i.id === flanela.variants[5].image_id)!.src,
|
|
57
|
+
);
|
|
58
|
+
expect(c).toMatchObject({
|
|
59
|
+
itemCount: 3,
|
|
60
|
+
subtotal: 549.7,
|
|
61
|
+
listSubtotal: 579.7,
|
|
62
|
+
savings: 30,
|
|
63
|
+
currency: "BRL",
|
|
64
|
+
unavailable: [],
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("asks only for the products in the cart (ids, deduped) and nothing for an empty cart", async () => {
|
|
69
|
+
await cart({ items: [item(xadrez, 0), item(xadrez, 1), item(gamer)] });
|
|
70
|
+
const url = new URL(String(fetchMock.mock.calls[0][0]));
|
|
71
|
+
expect(url.searchParams.get("ids")).toBe(`${xadrez.id},${gamer.id}`);
|
|
72
|
+
expect(url.searchParams.get("per_page")).toBe("30");
|
|
73
|
+
|
|
74
|
+
fetchMock.mockClear();
|
|
75
|
+
expect(await cart({ items: [] })).toMatchObject({ lines: [], itemCount: 0, subtotal: 0 });
|
|
76
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps quantities to stock (the checkout rejects more) and to 99", async () => {
|
|
80
|
+
const c = await cart({ items: [{ ...item(xadrez, 0), quantity: 50 }] });
|
|
81
|
+
expect(c.lines[0]).toMatchObject({ quantity: 17, maxQuantity: 17, adjusted: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("moves missing and sold-out variants out of the lines", async () => {
|
|
85
|
+
const soldOut = { ...item(gamer, 0), variantId: gamer.variants[0].id };
|
|
86
|
+
fetchMock.mockImplementationOnce(async () =>
|
|
87
|
+
Response.json({
|
|
88
|
+
data: [
|
|
89
|
+
{ ...gamer, variants: gamer.variants.map((v, i) => (i === 0 ? { ...v, stock: 0 } : v)) },
|
|
90
|
+
],
|
|
91
|
+
pagination: { page: 1, per_page: 30, total: 1, total_pages: 1 },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const c = await cart({
|
|
95
|
+
items: [
|
|
96
|
+
soldOut,
|
|
97
|
+
{ productId: gamer.id, variantId: 999, quantity: 1 },
|
|
98
|
+
{ productId: 1, variantId: 2, quantity: 1 },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
expect(c.lines).toEqual([]);
|
|
102
|
+
expect(c.unavailable).toEqual([
|
|
103
|
+
{ ...soldOut, reason: "out_of_stock" },
|
|
104
|
+
{ productId: gamer.id, variantId: 999, quantity: 1, reason: "not_found" },
|
|
105
|
+
{ productId: 1, variantId: 2, quantity: 1, reason: "not_found" },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("fetches in batches of 30 ids (the API limit)", async () => {
|
|
110
|
+
const items = Array.from({ length: 31 }, (_, i) => ({
|
|
111
|
+
productId: i + 1,
|
|
112
|
+
variantId: 1,
|
|
113
|
+
quantity: 1,
|
|
114
|
+
}));
|
|
115
|
+
await cart({ items });
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -205,7 +205,7 @@ describe("createCheckout", () => {
|
|
|
205
205
|
items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }],
|
|
206
206
|
coupon: " TEST ",
|
|
207
207
|
});
|
|
208
|
-
expect(res
|
|
208
|
+
expect(res).toEqual({ checkoutUrl: checkoutFixture.checkout_url });
|
|
209
209
|
const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body));
|
|
210
210
|
expect(body).toEqual({
|
|
211
211
|
line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }],
|
|
@@ -213,8 +213,27 @@ describe("createCheckout", () => {
|
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("
|
|
217
|
-
await
|
|
216
|
+
it("returns a typed error for an empty cart without calling the API", async () => {
|
|
217
|
+
expect(await createCheckout({ items: [] })).toEqual({
|
|
218
|
+
error: "invalid",
|
|
219
|
+
message: "Cannot checkout an empty cart",
|
|
220
|
+
});
|
|
218
221
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
219
222
|
});
|
|
223
|
+
|
|
224
|
+
it.each([
|
|
225
|
+
[422, "coupon_rejected", "coupon_rejected"],
|
|
226
|
+
[422, "checkout_rejected", "out_of_stock"],
|
|
227
|
+
[404, "resource_not_found", "unavailable"],
|
|
228
|
+
[400, "invalid_request", "invalid"],
|
|
229
|
+
[500, "boom", "unknown"],
|
|
230
|
+
])("maps the API's %i %s to a typed error (%s)", async (status, code, error) => {
|
|
231
|
+
fetchMock.mockImplementationOnce(async () =>
|
|
232
|
+
json({ error: { code, message: `msg ${code}` } }, status),
|
|
233
|
+
);
|
|
234
|
+
expect(await createCheckout({ items: [{ productId: 1, variantId: 2, quantity: 1 }] })).toEqual({
|
|
235
|
+
error,
|
|
236
|
+
message: `msg ${code}`,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
220
239
|
});
|
|
@@ -23,13 +23,41 @@ describe("configure", () => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("resolves the admin token and Turnstile secret as secrets and keeps storeUrl", async () => {
|
|
27
|
+
const keys: string[] = [];
|
|
28
|
+
await configure(
|
|
29
|
+
{
|
|
30
|
+
storeId: "1",
|
|
31
|
+
storeUrl: "https://loja.example/",
|
|
32
|
+
adminToken: { e: 1 },
|
|
33
|
+
turnstileSecret: { e: 2 },
|
|
34
|
+
allowUnverifiedRegistration: true,
|
|
35
|
+
},
|
|
36
|
+
async (_v, key) => {
|
|
37
|
+
keys.push(key);
|
|
38
|
+
return `secret-for-${key}`;
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
expect(keys).toEqual([
|
|
42
|
+
"NUVEMSHOP_STOREFRONT_TOKEN",
|
|
43
|
+
"NUVEMSHOP_ADMIN_TOKEN",
|
|
44
|
+
"NUVEMSHOP_TURNSTILE_SECRET",
|
|
45
|
+
]);
|
|
46
|
+
expect(getNuvemshopConfig()).toMatchObject({
|
|
47
|
+
storeUrl: "https://loja.example/",
|
|
48
|
+
adminToken: "secret-for-NUVEMSHOP_ADMIN_TOKEN",
|
|
49
|
+
turnstileSecret: "secret-for-NUVEMSHOP_TURNSTILE_SECRET",
|
|
50
|
+
allowUnverifiedRegistration: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
26
54
|
it("resolves the token through the secret resolver (env fallback key)", async () => {
|
|
27
|
-
|
|
55
|
+
const keys: string[] = [];
|
|
28
56
|
await configure({ storeId: "1", token: { encrypted: "x" } }, async (_v, key) => {
|
|
29
|
-
|
|
30
|
-
return "tok";
|
|
57
|
+
keys.push(key);
|
|
58
|
+
return key === "NUVEMSHOP_STOREFRONT_TOKEN" ? "tok" : null;
|
|
31
59
|
});
|
|
32
|
-
expect(
|
|
60
|
+
expect(keys[0]).toBe("NUVEMSHOP_STOREFRONT_TOKEN");
|
|
33
61
|
expect(getNuvemshopConfig().token).toBe("tok");
|
|
34
62
|
});
|
|
35
63
|
});
|
|
@@ -63,6 +91,12 @@ describe("nuvemshopOperationRouter", () => {
|
|
|
63
91
|
[`${base}/categories/calcados`, "categories.get"],
|
|
64
92
|
[`${base}/shipping-options`, "shipping.options"],
|
|
65
93
|
[`${base}/checkouts`, "checkout.create"],
|
|
94
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/login/", "store.account.login"],
|
|
95
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/logout/", "store.account.logout"],
|
|
96
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/", "store.account.page"],
|
|
97
|
+
["https://api.nuvemshop.com.br/v1/1/customers", "admin.customers"],
|
|
98
|
+
["https://api.nuvemshop.com.br/v1/1/customers/42", "admin.customers"],
|
|
99
|
+
["https://challenges.cloudflare.com/turnstile/v0/siteverify", "turnstile.verify"],
|
|
66
100
|
["https://example.com/other", undefined],
|
|
67
101
|
])("%s → %s", (url, op) => {
|
|
68
102
|
expect(nuvemshopOperationRouter(url, "GET")).toBe(op);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { storeFetch } from "../../store";
|
|
2
|
+
|
|
3
|
+
export interface Props {
|
|
4
|
+
email: string;
|
|
5
|
+
password: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type LoginResult =
|
|
9
|
+
| { ok: true }
|
|
10
|
+
| { ok: false; error: "invalid_credentials" | "email_not_validated" | "unknown" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @title Nuvemshop - Login
|
|
14
|
+
* @description Signs the buyer into the store session (the theme's login form, server-side).
|
|
15
|
+
* Success is a redirect to /account/; on failure the login page carries the reason.
|
|
16
|
+
*/
|
|
17
|
+
export default async function login(props: Props): Promise<LoginResult> {
|
|
18
|
+
if (!props?.email || !props?.password) return { ok: false, error: "invalid_credentials" };
|
|
19
|
+
const res = await storeFetch("/account/login/", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
form: { email: props.email, password: props.password },
|
|
22
|
+
});
|
|
23
|
+
if (res.location && new URL(res.location).pathname === "/account/") return { ok: true };
|
|
24
|
+
|
|
25
|
+
const page = await (await storeFetch("/account/login/", { cookie: res.cookie })).text();
|
|
26
|
+
if (page.includes("js-account-validation-pending"))
|
|
27
|
+
return { ok: false, error: "email_not_validated" };
|
|
28
|
+
if (page.includes("js-login-general-error")) return { ok: false, error: "invalid_credentials" };
|
|
29
|
+
return { ok: false, error: "unknown" };
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { storeFetch } from "../../store";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @title Nuvemshop - Logout
|
|
5
|
+
* @description Ends the store session (and forwards the expired session cookie).
|
|
6
|
+
*/
|
|
7
|
+
export default async function logout(_props: unknown): Promise<{ ok: true }> {
|
|
8
|
+
await storeFetch("/account/logout/");
|
|
9
|
+
return { ok: true };
|
|
10
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { nuvemshopAdmin } from "../../admin";
|
|
3
|
+
import { getNuvemshopConfig, nuvemshopFetch } from "../../client";
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
password: string;
|
|
9
|
+
phone?: string;
|
|
10
|
+
/** Cloudflare Turnstile token from the registration form. */
|
|
11
|
+
captchaToken?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RegisterResult =
|
|
15
|
+
| { ok: true; customerId: number; emailValidationRequired: true }
|
|
16
|
+
| {
|
|
17
|
+
ok: false;
|
|
18
|
+
error:
|
|
19
|
+
| "invalid_input"
|
|
20
|
+
| "email_taken"
|
|
21
|
+
| "captcha_failed"
|
|
22
|
+
| "captcha_not_configured"
|
|
23
|
+
| "unknown";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
27
|
+
|
|
28
|
+
async function verifyTurnstile(secret: string, token: string | undefined) {
|
|
29
|
+
if (!token) return false;
|
|
30
|
+
const remoteip = RequestContext.current?.request.headers.get("cf-connecting-ip") ?? "";
|
|
31
|
+
const res = await nuvemshopFetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
34
|
+
body: new URLSearchParams({
|
|
35
|
+
secret,
|
|
36
|
+
response: token,
|
|
37
|
+
...(remoteip && { remoteip }),
|
|
38
|
+
}).toString(),
|
|
39
|
+
});
|
|
40
|
+
return ((await res.json().catch(() => null)) as { success?: boolean } | null)?.success === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @title Nuvemshop - Register
|
|
45
|
+
* @description Creates a customer through the Admin API. The store's own form needs a reCAPTCHA
|
|
46
|
+
* bound to its domain; the Admin API has none, so this action requires Cloudflare Turnstile
|
|
47
|
+
* (`turnstileSecret`) unless `allowUnverifiedRegistration` is set. The store still emails a
|
|
48
|
+
* validation link before the first login.
|
|
49
|
+
*/
|
|
50
|
+
export default async function register(props: Props): Promise<RegisterResult> {
|
|
51
|
+
const { turnstileSecret, allowUnverifiedRegistration } = getNuvemshopConfig();
|
|
52
|
+
const name = props?.name?.trim();
|
|
53
|
+
const email = props?.email?.trim();
|
|
54
|
+
if (!name || !email || !EMAIL.test(email) || !props.password || props.password.length < 6) {
|
|
55
|
+
return { ok: false, error: "invalid_input" };
|
|
56
|
+
}
|
|
57
|
+
if (!allowUnverifiedRegistration) {
|
|
58
|
+
if (!turnstileSecret) return { ok: false, error: "captcha_not_configured" };
|
|
59
|
+
if (!(await verifyTurnstile(turnstileSecret, props.captchaToken))) {
|
|
60
|
+
return { ok: false, error: "captcha_failed" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const res = await nuvemshopAdmin("/customers", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
body: {
|
|
66
|
+
name,
|
|
67
|
+
email,
|
|
68
|
+
password: props.password,
|
|
69
|
+
...(props.phone && { phone: props.phone }),
|
|
70
|
+
send_email_invite: false,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (res.status === 422) {
|
|
74
|
+
const body = (await res.json().catch(() => ({}))) as { email?: string[] };
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: body.email?.some((m) => /unique/i.test(m)) ? "email_taken" : "invalid_input",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) return { ok: false, error: "unknown" };
|
|
81
|
+
const customer = (await res.json()) as { id: number };
|
|
82
|
+
return { ok: true, customerId: customer.id, emailValidationRequired: true };
|
|
83
|
+
}
|