diff --git a/README.md b/README.md index 1d06cc0..334377e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This project follows: 6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`. 7. Extension renders a button and opens `redirectUrl` in the current window. -Failures return `{ "success": false }`; the extension renders nothing and does not block the native Thank You Page. +Business failures return `{ "success": false }`; route-level validation errors can include an `error` object and non-2xx status. The extension renders nothing for either case and does not block the native Thank You Page. ## Local Setup @@ -153,7 +153,19 @@ Failure response: } ``` -CORS headers are returned for `POST` and `OPTIONS`; source trust relies on Shopify checkout session token validation. +Route-level validation failures include an error code and use the matching HTTP status: + +```json +{ + "success": false, + "error": { + "code": "ORDER_ID_REQUIRED", + "message": "orderId is required." + } +} +``` + +All route responses include `Cache-Control: no-store, max-age=0`. Authenticated `POST` and `OPTIONS` responses are wrapped with Shopify's checkout CORS helper; fallback CORS is only used when authentication itself fails before the helper is available. Source trust relies on Shopify checkout session token validation. ## Dreame Signing @@ -253,6 +265,8 @@ Current test coverage: - `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data. - `test/config.test.ts`: app-owned Metaobject default type and legacy type override. - `test/activity-flow.test.ts`: inactive status, non-zero status code, configurable success codes, configurable status active policy, successful push payload shape, push failure, missing email, missing customer ID. +- `test/activity-route.test.ts`: checkout auth/CORS/cache HTTP contract, invalid shop context, missing order ID, auth failure handling. +- `test/checkout-extension.test.ts`: extension app URL normalization, session-token POST request, non-2xx and invalid JSON fallback behavior. ## Deployment Steps diff --git a/app/routes/api.action.activity.check.tsx b/app/routes/api.action.activity.check.tsx index 6b3e31d..0b52b63 100644 --- a/app/routes/api.action.activity.check.tsx +++ b/app/routes/api.action.activity.check.tsx @@ -8,12 +8,23 @@ import { findSuccessfulPush, recordSuccessfulPush } from "../services/idempotenc import { logActivityPush } from "../services/logger.server"; import { getOrderDetails } from "../services/order.server"; -const CORS_HEADERS = { +const CACHE_CONTROL = "no-store, max-age=0"; +const CHECKOUT_CORS_HEADERS = ["Authorization", "Content-Type"]; +const FALLBACK_CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Allow-Methods": "POST, OPTIONS", + "Cache-Control": CACHE_CONTROL, }; +type CheckoutAuthResult = Awaited> & { + admin?: unknown; + session?: { shop?: string }; + shop?: string; +}; + +type CheckoutCors = CheckoutAuthResult["cors"]; + type ActivityRequestBody = { orderId?: string; orderNumber?: string; @@ -22,35 +33,58 @@ type ActivityRequestBody = { export async function loader({ request }: LoaderFunctionArgs) { if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); + return handleOptions(request); } - return json({ success: false }, { status: 405, headers: CORS_HEADERS }); + return fallbackCors( + errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, { + Allow: "POST", + }), + ); } export async function action({ request }: ActionFunctionArgs) { if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); + return handleOptions(request); } if (request.method !== "POST") { - return json({ success: false }, { status: 405, headers: CORS_HEADERS }); + return fallbackCors( + errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, { + Allow: "POST", + }), + ); + } + + let authResult: CheckoutAuthResult; + + try { + authResult = (await authenticate.public.checkout(request, { + corsHeaders: CHECKOUT_CORS_HEADERS, + })) as CheckoutAuthResult; + } catch (error) { + return authErrorResponse(error); + } + + const { cors, sessionToken } = authResult; + const shop = normalizeShopDomain(authResult.shop || authResult.session?.shop || sessionToken.dest); + if (!shop) { + return checkoutCors( + cors, + errorResponse("INVALID_SHOP_CONTEXT", "A valid Shopify shop context is required.", 401), + ); + } + + const body = await parseJsonBody(request); + if (!body) { + return checkoutCors(cors, errorResponse("INVALID_JSON", "Request body must be valid JSON.", 400)); + } + + if (typeof body.orderId !== "string" || body.orderId.trim() === "") { + return checkoutCors(cors, errorResponse("ORDER_ID_REQUIRED", "orderId is required.", 400)); } try { - const checkoutAuth = (authenticate as unknown as { public?: { checkout?: (request: Request) => Promise } }) - .public?.checkout; - if (!checkoutAuth) { - throw new Error("Shopify checkout authentication is unavailable"); - } - - const authResult = (await checkoutAuth(request)) as { - shop?: string; - admin?: unknown; - sessionToken?: { dest?: string; iss?: string }; - session?: { shop?: string }; - }; - const shop = authResult.shop || authResult.session?.shop || shopFromSessionToken(authResult.sessionToken); const adminResult = authResult.admin ? { admin: authResult.admin } : shop ? await unauthenticated.admin(shop) : null; const admin = adminResult?.admin as { graphql: (query: string, options?: { variables?: Record }) => Promise; @@ -59,15 +93,10 @@ export async function action({ request }: ActionFunctionArgs) { throw new Error("Invalid Shopify checkout session"); } - const body = (await request.json()) as ActivityRequestBody; - if (!body.orderId) { - return json({ success: false }, { headers: CORS_HEADERS }); - } - const result = await runActivityCheck( { shop, - orderId: body.orderId, + orderId: body.orderId.trim(), orderNumber: body.orderNumber, locale: body.locale, }, @@ -84,21 +113,90 @@ export async function action({ request }: ActionFunctionArgs) { }, ); - return json(result, { headers: CORS_HEADERS }); + return checkoutCors(cors, json(result, { headers: noStoreHeaders() })); } catch { - return json({ success: false }, { headers: CORS_HEADERS }); + return checkoutCors(cors, errorResponse("ACTIVITY_CHECK_FAILED", "Activity check failed.", 500)); } } -function shopFromSessionToken(sessionToken: { dest?: string; iss?: string } | undefined): string | undefined { - const source = sessionToken?.dest || sessionToken?.iss; - if (!source) { - return undefined; +async function handleOptions(request: Request): Promise { + try { + const { cors } = await authenticate.public.checkout(request, { + corsHeaders: CHECKOUT_CORS_HEADERS, + }); + return checkoutCors(cors, new Response(null, { status: 204, headers: noStoreHeaders() })); + } catch (error) { + return authErrorResponse(error); + } +} + +async function parseJsonBody(request: Request): Promise { + try { + return (await request.json()) as ActivityRequestBody; + } catch { + return null; + } +} + +function normalizeShopDomain(value: unknown): string | null { + if (typeof value !== "string" || value.trim() === "") { + return null; } try { - return new URL(source).hostname; + const url = value.includes("://") ? value : `https://${value}`; + const hostname = new URL(url).hostname.toLowerCase(); + return hostname.endsWith(".myshopify.com") ? hostname : null; } catch { - return source.replace(/^https?:\/\//, "").split("/")[0]; + return null; } } + +function errorResponse(code: string, message: string, status: number, headers: HeadersInit = {}): Response { + return json( + { + success: false, + error: { + code, + message, + }, + }, + { + status, + headers: { + ...headers, + ...noStoreHeaders(), + }, + }, + ); +} + +function authErrorResponse(error: unknown): Response { + if (error instanceof Response) { + return fallbackCors(withNoStore(error)); + } + + return fallbackCors(errorResponse("CHECKOUT_AUTHENTICATION_FAILED", "Checkout authentication failed.", 401)); +} + +function checkoutCors(cors: CheckoutCors, response: Response): Response { + return cors(withNoStore(response)); +} + +function fallbackCors(response: Response): Response { + for (const [key, value] of Object.entries(FALLBACK_CORS_HEADERS)) { + response.headers.set(key, value); + } + return response; +} + +function withNoStore(response: Response): Response { + response.headers.set("Cache-Control", CACHE_CONTROL); + return response; +} + +function noStoreHeaders(): HeadersInit { + return { + "Cache-Control": CACHE_CONTROL, + }; +} diff --git a/docs/shopify-deployment-runbook.md b/docs/shopify-deployment-runbook.md index 4ff5b2b..65be7e5 100644 --- a/docs/shopify-deployment-runbook.md +++ b/docs/shopify-deployment-runbook.md @@ -218,7 +218,7 @@ curl -i -X OPTIONS https:///api/action/activity/check - 首页或健康页返回 200。 - OPTIONS 返回 204,并包含 CORS headers。 -- 未带 Shopify checkout session token 的 POST 应返回 `{"success":false}`。 +- 未带 Shopify checkout session token 的 POST 应返回 HTTP 4xx,且不应触发第三方推送。 ## 11. 部署 Shopify 配置和扩展 diff --git a/docs/追觅Shopify App 最终方案与上线说明.md b/docs/追觅Shopify App 最终方案与上线说明.md index 1c6b32a..44eedfc 100644 --- a/docs/追觅Shopify App 最终方案与上线说明.md +++ b/docs/追觅Shopify App 最终方案与上线说明.md @@ -97,11 +97,25 @@ Content-Type: application/json } ``` +路由级校验失败会返回对应 HTTP 状态码和错误码,例如: + +```json +{ + "success": false, + "error": { + "code": "ORDER_ID_REQUIRED", + "message": "orderId is required." + } +} +``` + 说明: - 前端不传 `api_secret`。 - 前端传入的订单字段只作为最小定位信息,不作为可信业务数据。 - 完整订单数据由后端通过 Shopify Admin GraphQL 查询。 +- `POST` 和 `OPTIONS` 响应带 `Cache-Control: no-store, max-age=0`。 +- 已认证的 Checkout Extension 响应使用 Shopify checkout CORS helper 包装;认证失败前无法拿到 helper 时使用最小 fallback CORS。 - CORS 对 Checkout Extension 放开,来源可信由 session token 校验负责。 ## 5. Shopify 配置 @@ -445,7 +459,7 @@ curl -i -X OPTIONS https:///api/action/activity/check - 首页或健康页返回 200。 - OPTIONS 返回 204,并包含 CORS headers。 -- 未带 session token 的 POST 不应成功推送。 +- 未带 session token 的 POST 应返回 HTTP 4xx,不应成功推送。 ### 11.5 部署 Shopify 配置和 Extension diff --git a/extensions/thank-you-activity/src/Checkout.tsx b/extensions/thank-you-activity/src/Checkout.tsx index ec1318e..b832361 100644 --- a/extensions/thank-you-activity/src/Checkout.tsx +++ b/extensions/thank-you-activity/src/Checkout.tsx @@ -1,60 +1,33 @@ import { useEffect, useState } from "react"; import { reactExtension, Button, InlineStack, useApi } from "@shopify/ui-extensions-react/checkout"; - -type ActivityResponse = - | { - success: true; - redirectUrl: string; - buttonText: string; - } - | { - success: false; - }; +import { + getAppUrlSetting, + getCheckoutLocale, + getCheckoutOrder, + requestActivityCheck, + type ActivityResponse, + type CheckoutExtensionApi, +} from "./activity-api"; export default reactExtension("purchase.thank-you.block.render", () => ); function DreameActivity() { - const api = useApi<"purchase.thank-you.block.render">() as any; + const api = useApi<"purchase.thank-you.block.render">() as unknown as CheckoutExtensionApi; const [response, setResponse] = useState({ success: false }); useEffect(() => { let cancelled = false; async function checkActivity() { - const appUrl = String(api.settings?.current?.app_url || "").replace(/\/$/, ""); - const order = api.orderConfirmation?.current?.order || api.orderConfirmation?.value?.order; - const orderId = order?.id; - if (!appUrl || !orderId) { - return; - } + const result = await requestActivityCheck({ + appUrl: getAppUrlSetting(api), + order: getCheckoutOrder(api), + locale: getCheckoutLocale(api), + getSessionToken: () => api.sessionToken.get(), + }); - try { - const token = await api.sessionToken.get(); - const locale = - api.localization?.current?.language?.isoCode || - api.localization?.value?.language?.isoCode || - api.i18n?.locale || - "en"; - const result = (await fetch(`${appUrl}/api/action/activity/check`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - orderId, - orderNumber: order?.name, - locale, - }), - }).then((res) => res.json())) as ActivityResponse; - - if (!cancelled) { - setResponse(result.success ? result : { success: false }); - } - } catch { - if (!cancelled) { - setResponse({ success: false }); - } + if (!cancelled) { + setResponse(result); } } diff --git a/extensions/thank-you-activity/src/activity-api.ts b/extensions/thank-you-activity/src/activity-api.ts new file mode 100644 index 0000000..d2f4e2e --- /dev/null +++ b/extensions/thank-you-activity/src/activity-api.ts @@ -0,0 +1,181 @@ +export type ActivityResponse = + | { + success: true; + redirectUrl: string; + buttonText: string; + } + | { + success: false; + }; + +export type CheckoutOrder = { + id?: string; + name?: string; +}; + +export type CheckoutExtensionApi = { + settings?: { + current?: { + app_url?: unknown; + }; + value?: { + app_url?: unknown; + }; + }; + orderConfirmation?: { + current?: { + order?: CheckoutOrder; + }; + value?: { + order?: CheckoutOrder; + }; + }; + localization?: { + current?: { + language?: { + isoCode?: string; + }; + }; + value?: { + language?: { + isoCode?: string; + }; + }; + }; + i18n?: { + locale?: string; + }; + sessionToken: { + get: () => Promise; + }; +}; + +type FetchLike = (input: string, init: RequestInit) => Promise; + +type RequestActivityCheckOptions = { + appUrl: unknown; + order?: CheckoutOrder; + locale: string; + getSessionToken: () => Promise; + fetchImpl?: FetchLike; + timeoutMs?: number; +}; + +export const DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS = 10_000; + +export function normalizeAppUrl(rawValue: unknown): string | null { + if (typeof rawValue !== "string" || rawValue.trim() === "") { + return null; + } + + try { + const url = new URL(rawValue.trim()); + if (url.protocol !== "https:" || url.username || url.password) { + return null; + } + + const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return `${url.origin}${pathname}`; + } catch { + return null; + } +} + +export async function requestActivityCheck({ + appUrl, + order, + locale, + getSessionToken, + fetchImpl = fetch, + timeoutMs = DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS, +}: RequestActivityCheckOptions): Promise { + const baseUrl = normalizeAppUrl(appUrl); + if (!baseUrl || !order?.id) { + return hiddenActivityResponse(); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const token = await getSessionToken(); + if (typeof token !== "string" || token.trim() === "") { + return hiddenActivityResponse(); + } + + const endpoint = new URL("api/action/activity/check", `${baseUrl}/`).toString(); + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + orderId: order.id, + orderNumber: order.name, + locale, + }), + cache: "no-store", + signal: controller.signal, + }); + + if (!response.ok) { + return hiddenActivityResponse(); + } + + const payload = await response.json().catch(() => null); + return toActivityResponse(payload); + } catch { + return hiddenActivityResponse(); + } finally { + clearTimeout(timeout); + } +} + +export function getAppUrlSetting(api: CheckoutExtensionApi): unknown { + return api.settings?.current?.app_url ?? api.settings?.value?.app_url; +} + +export function getCheckoutOrder(api: CheckoutExtensionApi): CheckoutOrder | undefined { + return api.orderConfirmation?.current?.order ?? api.orderConfirmation?.value?.order; +} + +export function getCheckoutLocale(api: CheckoutExtensionApi): string { + return ( + api.localization?.current?.language?.isoCode || + api.localization?.value?.language?.isoCode || + api.i18n?.locale || + "en" + ); +} + +function toActivityResponse(payload: unknown): ActivityResponse { + if (!payload || typeof payload !== "object") { + return hiddenActivityResponse(); + } + + const response = payload as Record; + if (response.success !== true) { + return hiddenActivityResponse(); + } + + if (typeof response.redirectUrl !== "string" || response.redirectUrl.trim() === "") { + return hiddenActivityResponse(); + } + + if (typeof response.buttonText !== "string" || response.buttonText.trim() === "") { + return hiddenActivityResponse(); + } + + return { + success: true, + redirectUrl: response.redirectUrl, + buttonText: response.buttonText, + }; +} + +function hiddenActivityResponse(): ActivityResponse { + return { + success: false, + }; +} diff --git a/test/activity-route.test.ts b/test/activity-route.test.ts new file mode 100644 index 0000000..f448901 --- /dev/null +++ b/test/activity-route.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; + +const mocks = vi.hoisted(() => ({ + checkout: vi.fn(), + unauthenticatedAdmin: vi.fn(), + runActivityCheck: vi.fn(), +})); + +vi.mock("../app/shopify.server", () => ({ + authenticate: { + public: { + checkout: mocks.checkout, + }, + }, + unauthenticated: { + admin: mocks.unauthenticatedAdmin, + }, +})); + +vi.mock("../app/services/activity.server", () => ({ + runActivityCheck: mocks.runActivityCheck, +})); + +vi.mock("../app/services/config.server", () => ({ + getActivityConfig: vi.fn(), +})); + +vi.mock("../app/services/dreame-client.server", () => ({ + callPush: vi.fn(), + callStatus: vi.fn(), +})); + +vi.mock("../app/services/idempotency.server", () => ({ + findSuccessfulPush: vi.fn(), + recordSuccessfulPush: vi.fn(), +})); + +vi.mock("../app/services/logger.server", () => ({ + logActivityPush: vi.fn(), +})); + +vi.mock("../app/services/order.server", () => ({ + getOrderDetails: vi.fn(), +})); + +function cors(response: Response): Response { + response.headers.set("X-Cors-Applied", "true"); + return response; +} + +async function postAction(body: unknown): Promise { + const { action } = await import("../app/routes/api.action.activity.check"); + return action({ + request: new Request("https://app.example.com/api/action/activity/check", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }), + context: {}, + params: {}, + } as ActionFunctionArgs) as Promise; +} + +describe("activity check route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkout.mockResolvedValue({ + sessionToken: { + dest: "https://demo.myshopify.com", + }, + cors, + }); + mocks.unauthenticatedAdmin.mockResolvedValue({ + admin: { + graphql: vi.fn(), + }, + }); + mocks.runActivityCheck.mockResolvedValue({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "Claim", + }); + }); + + test("wraps success responses with Shopify cors and no-store cache headers", async () => { + const response = await postAction({ + orderId: "gid://shopify/Order/123", + orderNumber: "#1001", + locale: "en", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Cors-Applied")).toBe("true"); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.json()).resolves.toEqual({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "Claim", + }); + expect(mocks.runActivityCheck).toHaveBeenCalledWith( + expect.objectContaining({ + shop: "demo.myshopify.com", + orderId: "gid://shopify/Order/123", + }), + expect.any(Object), + ); + }); + + test("rejects checkout tokens without a myshopify shop context", async () => { + mocks.checkout.mockResolvedValue({ + sessionToken: { + dest: "https://not-shop.example.com", + }, + cors, + }); + + const response = await postAction({ + orderId: "gid://shopify/Order/123", + }); + + expect(response.status).toBe(401); + expect(response.headers.get("X-Cors-Applied")).toBe("true"); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.json()).resolves.toEqual({ + success: false, + error: { + code: "INVALID_SHOP_CONTEXT", + message: "A valid Shopify shop context is required.", + }, + }); + expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled(); + expect(mocks.runActivityCheck).not.toHaveBeenCalled(); + }); + + test("returns a bad request when orderId is missing", async () => { + const response = await postAction({ + orderNumber: "#1001", + }); + + expect(response.status).toBe(400); + expect(response.headers.get("X-Cors-Applied")).toBe("true"); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + await expect(response.json()).resolves.toEqual({ + success: false, + error: { + code: "ORDER_ID_REQUIRED", + message: "orderId is required.", + }, + }); + expect(mocks.runActivityCheck).not.toHaveBeenCalled(); + }); + + test("preserves Shopify authentication failures as HTTP errors", async () => { + mocks.checkout.mockRejectedValue(new Response("Unauthorized", { status: 401 })); + + const response = await postAction({ + orderId: "gid://shopify/Order/123", + }); + + expect(response.status).toBe(401); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(await response.text()).toBe("Unauthorized"); + expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled(); + expect(mocks.runActivityCheck).not.toHaveBeenCalled(); + }); + + test("uses Shopify checkout authentication for OPTIONS responses", async () => { + const { loader } = await import("../app/routes/api.action.activity.check"); + const response = await loader({ + request: new Request("https://app.example.com/api/action/activity/check", { + method: "OPTIONS", + }), + context: {}, + params: {}, + } as LoaderFunctionArgs); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Cors-Applied")).toBe("true"); + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(mocks.checkout).toHaveBeenCalled(); + }); +}); diff --git a/test/checkout-extension.test.ts b/test/checkout-extension.test.ts new file mode 100644 index 0000000..a0b03e5 --- /dev/null +++ b/test/checkout-extension.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, vi } from "vitest"; +import { normalizeAppUrl, requestActivityCheck } from "../extensions/thank-you-activity/src/activity-api"; + +describe("checkout extension activity api", () => { + test("normalizes only HTTPS app URLs", () => { + expect(normalizeAppUrl("https://app.example.com/")).toBe("https://app.example.com"); + expect(normalizeAppUrl(" https://app.example.com/path/ ")).toBe("https://app.example.com/path"); + expect(normalizeAppUrl("http://localhost:3000")).toBeNull(); + expect(normalizeAppUrl("")).toBeNull(); + }); + + test("posts order context with a fresh checkout session token", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "Claim", + }), + { status: 200 }, + ), + ); + const getSessionToken = vi.fn(async () => "checkout-token"); + + const result = await requestActivityCheck({ + appUrl: "https://app.example.com/", + order: { + id: "gid://shopify/Order/123", + name: "#1001", + }, + locale: "en", + getSessionToken, + fetchImpl, + timeoutMs: 100, + }); + + expect(result).toEqual({ + success: true, + redirectUrl: "https://activity.example.com", + buttonText: "Claim", + }); + expect(getSessionToken).toHaveBeenCalledOnce(); + expect(fetchImpl).toHaveBeenCalledWith( + "https://app.example.com/api/action/activity/check", + expect.objectContaining({ + method: "POST", + cache: "no-store", + headers: { + Authorization: "Bearer checkout-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + orderId: "gid://shopify/Order/123", + orderNumber: "#1001", + locale: "en", + }), + signal: expect.any(AbortSignal), + }), + ); + }); + + test("returns hidden state for non-2xx and invalid JSON responses", async () => { + const nonOk = await requestActivityCheck({ + appUrl: "https://app.example.com", + order: { id: "gid://shopify/Order/123" }, + locale: "en", + getSessionToken: async () => "checkout-token", + fetchImpl: async () => new Response(JSON.stringify({ success: true }), { status: 500 }), + timeoutMs: 100, + }); + + const invalidJson = await requestActivityCheck({ + appUrl: "https://app.example.com", + order: { id: "gid://shopify/Order/123" }, + locale: "en", + getSessionToken: async () => "checkout-token", + fetchImpl: async () => new Response("not json", { status: 200 }), + timeoutMs: 100, + }); + + expect(nonOk).toEqual({ success: false }); + expect(invalidJson).toEqual({ success: false }); + }); + + test("does not call the backend when app URL or order ID is unavailable", async () => { + const fetchImpl = vi.fn(); + + const invalidUrl = await requestActivityCheck({ + appUrl: "http://localhost:3000", + order: { id: "gid://shopify/Order/123" }, + locale: "en", + getSessionToken: async () => "checkout-token", + fetchImpl, + timeoutMs: 100, + }); + const missingOrder = await requestActivityCheck({ + appUrl: "https://app.example.com", + order: undefined, + locale: "en", + getSessionToken: async () => "checkout-token", + fetchImpl, + timeoutMs: 100, + }); + + expect(invalidUrl).toEqual({ success: false }); + expect(missingOrder).toEqual({ success: false }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +});