diff --git a/bench/tasks/doordash.com/domain.json b/bench/tasks/doordash.com/domain.json new file mode 100644 index 00000000..780acdc8 --- /dev/null +++ b/bench/tasks/doordash.com/domain.json @@ -0,0 +1,9 @@ +{ + "needs_sign_in": { + "get_cart": "reads the signed-in account's carts", + "get_checkout_summary": "reads the signed-in account's carts" + }, + "actions": { + "set_delivery_address": "sets a signed-out visit's delivery address, as the header's address picker does" + } +} diff --git a/domains/doordash.com/README.md b/domains/doordash.com/README.md index c28773f7..2e368c5b 100644 --- a/domains/doordash.com/README.md +++ b/domains/doordash.com/README.md @@ -3,6 +3,11 @@ | tool | description | |------|-------------| | create_business_profile | Creates the signed-in account's business profile with a receipt email and a new credit/debit card as the business payment method. | +| get_cart | Reads open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. | +| get_checkout_summary | Prepares a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. | +| get_store | Reads one DoorDash store by store_id: details, hours, menus, and whether delivery and pickup can be ordered now with the site's estimate and delivery-fee text. | | login | Signs in to a DoorDash consumer account with email and password, or submits a visible six-digit verification code; confirms an existing session without submitting. | | register | Creates a DoorDash consumer account from the email sign-up form, or submits a visible six-digit sign-up verification code; refuses when already signed in. | +| remove_cart_item | Removes exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. | | save_address | Makes a delivery address the account's current default: selects it if already saved, otherwise adds it from DoorDash's autocomplete with optional details. | +| set_delivery_address | Sets a signed-out visit's delivery address from DoorDash's address autocomplete, as the header's address picker does, and confirms it from the site's answer. | diff --git a/domains/doordash.com/get_cart.js b/domains/doordash.com/get_cart.js new file mode 100644 index 00000000..8f012108 --- /dev/null +++ b/domains/doordash.com/get_cart.js @@ -0,0 +1,208 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_cart", + description: "Read open DoorDash carts, line ids/options/quantities, currency and provisional subtotal/total/fee lines from the current authorized session. Requires expected_account_email (the intended account supplied by the user); checks the signed-in account before reading private carts, never returns email/payment data. Returns at most 20 carts, 100 lines and 50 options per line with truncation flags and a revision for remove_cart_item. Delivery context states the header basis and saved default region, not a verified cart address. Does not open checkout, set an address, add items, pay or place orders. Menu/item reads and adding items are native tools, not duplicated. Empty open-cart list is a valid success.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","carts","carts_truncated","delivery_context"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + carts: { + type: "array", + maxItems: 20, + items: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + } + }, + carts_truncated: {"type":"boolean"}, + delivery_context: { + type: "object", + additionalProperties: false, + required: ["basis","default_address_region","note"], + properties: { + basis: {"enum":["selected_address","no_address_selected","unknown"]}, + default_address_region: { + type: "object", + additionalProperties: false, + required: ["city","state","postal_code","country_code"], + properties: { + city: {"type":["string","null"],"maxLength":200}, + state: {"type":["string","null"],"maxLength":200}, + postal_code: {"type":["string","null"],"maxLength":200}, + country_code: {"type":["string","null"],"maxLength":200} + } + }, + note: {"type":"string","maxLength":500} + } + } + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail("cancelled", "Cancelled before the request."); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail("site_error", "DoorDash did not answer or the read was cancelled."); } + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail("site_changed", "DoorDash returned an oversized response."); + let j; + try { j = JSON.parse(raw); } catch { throw fail("blocked", "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually."); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, "DoorDash rejected the query. No private server error text is returned."); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail("site_changed", "DoorDash's response is incomplete."); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest defaultAddress { city state zipCode countryCode } } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + const deliveryContext = c => { + const label = (document.querySelector("[data-testid='addressTextButton']")?.innerText || "").trim(); + const basis = label ? /^(?:your address|tu direcci[oó]n)$/i.test(label) ? "no_address_selected" : /\d/.test(label) ? "selected_address" : "unknown" : document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"; + return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; + }; + try { + await readAccount(); + const carts = await readCarts(); + const account = await readAccount(); + const output = { status: "ok", account_matched: true, carts: await Promise.all(carts.slice(0, 20).map(project)), carts_truncated: carts.length > 20, delivery_context: deliveryContext(account) }; + if (new TextEncoder().encode(JSON.stringify(output)).byteLength > 180000) return fail("blocked", "The cart list exceeds the safe output limit; inspect the site manually."); + return output; + } catch (error) { + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/domains/doordash.com/get_checkout_summary.js b/domains/doordash.com/get_checkout_summary.js new file mode 100644 index 00000000..1f45bdd2 --- /dev/null +++ b/domains/doordash.com/get_checkout_summary.js @@ -0,0 +1,209 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_checkout_summary", + description: "Prepare a read-only DoorDash order summary for an exact open cart: lines, provisional fees/totals, currency and explicit delivery context, plus a manually opened checkout URL. Requires intended expected_account_email and cart_id. Reads open carts only; does not load checkout, reserve inventory, select/change an address, tip, payment or scheduling, accept terms, or place an order. ready_for_order is always false: no trusted human approval path is provided by this runtime. Totals can be incomplete; final cost/account/address must be reviewed in the site UI. Not an execution tool.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email","cart_id"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + }, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"} + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","cart","delivery_context","checkout_url","ready_for_order","execution_blocker"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + cart: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + }, + delivery_context: { + type: "object", + additionalProperties: false, + required: ["basis","default_address_region","note"], + properties: { + basis: {"enum":["selected_address","no_address_selected","unknown"]}, + default_address_region: { + type: "object", + additionalProperties: false, + required: ["city","state","postal_code","country_code"], + properties: { + city: {"type":["string","null"],"maxLength":200}, + state: {"type":["string","null"],"maxLength":200}, + postal_code: {"type":["string","null"],"maxLength":200}, + country_code: {"type":["string","null"],"maxLength":200} + } + }, + note: {"type":"string","maxLength":500} + } + }, + checkout_url: {"type":"string","pattern":"^https://www\\.doordash\\.com/consumer/checkout/\\?order_cart_id=[A-Za-z0-9_-]+$"}, + ready_for_order: {"const":false}, + execution_blocker: {"type":"string","maxLength":300} + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email","cart_id"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(input.cart_id)) return fail("invalid_input", "Invalid cart_id. No request was made."); + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail("cancelled", "Cancelled before the request."); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail("site_error", "DoorDash did not answer or the read was cancelled."); } + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail("site_changed", "DoorDash returned an oversized response."); + let j; + try { j = JSON.parse(raw); } catch { throw fail("blocked", "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually."); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, "DoorDash rejected the query. No private server error text is returned."); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail("site_changed", "DoorDash's response is incomplete."); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest defaultAddress { city state zipCode countryCode } } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + const deliveryContext = c => { + const label = (document.querySelector("[data-testid='addressTextButton']")?.innerText || "").trim(); + const basis = label ? /^(?:your address|tu direcci[oó]n)$/i.test(label) ? "no_address_selected" : /\d/.test(label) ? "selected_address" : "unknown" : document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"; + return { basis, default_address_region: { city: text(c.defaultAddress?.city), state: text(c.defaultAddress?.state), postal_code: text(c.defaultAddress?.zipCode), country_code: text(c.defaultAddress?.countryCode) }, note: "Basis describes the current header, not a verified cart delivery address. The region is the account's saved default, which may differ. No address is selected or changed. Totals and fees are provisional, not a payable quote." }; + }; + try { + await readAccount(); + const carts = await readCarts(); + const account = await readAccount(); + const matching = carts.filter(c => c?.id === input.cart_id); + if (matching.length !== 1) return fail("not_found", "The exact open cart was not found in this account."); + const projected = await project(matching[0]); + if (new TextEncoder().encode(JSON.stringify(projected)).byteLength > 170000) return fail("blocked", "The cart exceeds safe summary bounds; inspect the site manually."); + return { status: "ok", account_matched: true, cart: projected, delivery_context: deliveryContext(account), checkout_url: "https://www.doordash.com/consumer/checkout/?order_cart_id=" + input.cart_id, ready_for_order: false, execution_blocker: "Final order placement/payment is unshipped: no supported trusted human approval enforcement exists. Review the exact account, items, address, price and terms manually on DoorDash." }; + } catch (error) { + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/domains/doordash.com/get_store.js b/domains/doordash.com/get_store.js new file mode 100644 index 00000000..e454ad7d --- /dev/null +++ b/domains/doordash.com/get_store.js @@ -0,0 +1,261 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "get_store", + description: "Get one DoorDash (www.doordash.com) store by store_id (the id from search_restaurants or the number at the end of a doordash.com/store/-/ URL, without leading zeros): name, cuisines, price level, rating, the store's own address and coordinates, timezone, whether it offers delivery, pickup, group orders, catering and scheduling, open-hours schedules, its menus (menu_id is the menuId that the site's own add_to_cart tool needs), and whether delivery and pickup can be ordered right now with an estimate range and the site's delivery-fee text. Delivery availability and estimate are computed by DoorDash for the delivery address selected in this browser session, not for the store alone, so delivery_context.basis states which case applies: selected_address (the page header shows an address, so the delivery figures are for that address) or no_address_selected (the header shows the site's placeholder or the signed-out landing page shows its address form: DoorDash then answers for a placeholder location and every store reads 'Too far away') or unknown (the header text is not recognised). Delivery available, its reason, estimate and fee text, and too_far_from_consumer are returned only for selected_address and are null otherwise; pickup, hours and store details do not depend on it. signed_in is a cookie-based hint, not proof. This tool never selects or changes an address, never returns address text, and returns no customer distance. It does not return menu items (use the site's get_restaurant_menu on the store page), reviews, or a fee quote: the fee text depends on the address, the account's benefits and the basket. Convenience and grocery stores return their header details with menu null. Read-only; works signed out; runs from any www.doordash.com page without navigating.", + inputSchema: { + type: "object", additionalProperties: false, required: ["store_id"], + properties: { + store_id: { type: "string", pattern: "^[1-9][0-9]{0,11}$", description: "DoorDash store id, digits only and without leading zeros, e.g. the id field returned by search_restaurants." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, + required: ["status", "store_id", "name", "url", "store_type", "description", "cuisines", "price_range", "rating", "address", "timezone", "offers", "operation_status", "operation_text", "hours", "delivery", "pickup", "delivery_context", "menu"], + properties: { + status: { const: "ok" }, + store_id: { type: "string", pattern: "^[1-9][0-9]{0,11}$" }, + name: { type: "string", minLength: 1 }, + url: { type: "string", pattern: "^https://www\\.doordash\\.com/(convenience/)?store/[1-9][0-9]*/$", description: "Canonical store URL; the site redirects it to the slugged form." }, + store_type: { enum: ["restaurant", "convenience"], description: "convenience covers grocery and retail stores, whose catalog is not a menu." }, + description: { type: ["string", "null"] }, + cuisines: { type: "array", maxItems: 10, items: { type: "string" }, description: "The site's business tags, e.g. Italian, Pizza." }, + price_range: { + type: "object", additionalProperties: false, required: ["level", "text"], + properties: { level: { type: ["integer", "null"], minimum: 1, maximum: 4 }, text: { type: ["string", "null"], description: "$ to $$$$ as the site shows it." } } + }, + rating: { + type: "object", additionalProperties: false, required: ["average", "count", "count_text", "is_new"], + properties: { average: { type: ["number", "null"] }, count: { type: ["integer", "null"] }, count_text: { type: ["string", "null"], description: "For example (9k+)." }, is_new: { type: ["boolean", "null"] } } + }, + address: { + type: "object", additionalProperties: false, required: ["street", "city", "state", "latitude", "longitude"], + description: "The store's own address, not the customer's.", + properties: { street: { type: ["string", "null"] }, city: { type: ["string", "null"] }, state: { type: ["string", "null"] }, latitude: { type: ["number", "null"] }, longitude: { type: ["number", "null"] } } + }, + timezone: { type: ["string", "null"] }, + offers: { + type: "object", additionalProperties: false, required: ["delivery", "pickup", "group_order", "catering", "scheduling", "dashpass_partner"], + description: "Which services the store supports in general; not whether they can be ordered right now (see delivery and pickup).", + properties: { + delivery: { type: ["boolean", "null"] }, pickup: { type: ["boolean", "null"] }, group_order: { type: ["boolean", "null"] }, + catering: { type: ["boolean", "null"] }, scheduling: { type: ["boolean", "null"] }, dashpass_partner: { type: ["boolean", "null"] } + } + }, + operation_status: { type: ["string", "null"], description: "The site's operating status for DoorDash orders, e.g. OPEN. Pass-through; other values are not enumerated." }, + operation_text: { type: ["string", "null"], description: "For example: Accepting DoorDash orders until 2:40 AM." }, + hours: { + type: "object", additionalProperties: false, required: ["store", "doordash_orders"], + properties: { + store: { type: "array", maxItems: 7, items: { type: "object", additionalProperties: false, required: ["day", "slots"], properties: { day: { type: "string" }, slots: { type: "array", maxItems: 6, items: { type: "string" }, description: "Open periods as the site prints them, e.g. 10 AM - 2:40 AM." } } }, description: "The store's own hours of operation." }, + doordash_orders: { type: "array", maxItems: 7, items: { type: "object", additionalProperties: false, required: ["day", "slots"], properties: { day: { type: "string" }, slots: { type: "array", maxItems: 6, items: { type: "string" }, description: "Open periods as the site prints them, e.g. 10 AM - 2:40 AM." } } }, description: "The hours in which the store accepts DoorDash orders." } + } + }, + delivery: { + type: "object", additionalProperties: false, required: ["available", "unavailable_reason", "unavailable_message", "estimate_minutes", "fee_display"], + description: "Delivery to the session's delivery context (see delivery_context).", + properties: { + available: { type: ["boolean", "null"], description: "True when DoorDash reports delivery can be ordered now to the selected address. Null unless delivery_context.basis is selected_address, or when the site did not say." }, + unavailable_reason: { type: ["string", "null"], description: "The site's reason when not available, e.g. Too far away. Null unless the basis is selected_address." }, + unavailable_message: { type: ["string", "null"], description: "Null unless the basis is selected_address." }, + estimate_minutes: { type: ["string", "null"], description: "The site's estimate range, e.g. 27 - 37. Null unless delivery is available: the site sends a range even for unavailable delivery." }, + fee_display: { + type: "object", additionalProperties: false, required: ["title", "subtitle", "text", "is_surging"], + description: "DoorDash's own delivery-fee strings, unparsed. Not a quote: they depend on the address, account benefits and basket. All null unless the basis is selected_address.", + properties: { title: { type: ["string", "null"] }, subtitle: { type: ["string", "null"] }, text: { type: ["string", "null"] }, is_surging: { type: ["boolean", "null"] } } + } + } + }, + pickup: { + type: "object", additionalProperties: false, required: ["available", "unavailable_reason", "estimate_minutes"], + properties: { + available: { type: ["boolean", "null"], description: "True when DoorDash reports pickup can be ordered now." }, + unavailable_reason: { type: ["string", "null"] }, + estimate_minutes: { type: ["string", "null"], description: "Null unless pickup is available." } + } + }, + delivery_context: { + type: "object", additionalProperties: false, required: ["basis", "address_selected", "signed_in", "too_far_from_consumer", "note"], + properties: { + basis: { enum: ["selected_address", "no_address_selected", "unknown"], description: "What the delivery figures describe. selected_address: the header of this page shows an address (text containing a digit that is not the placeholder), so delivery figures are DoorDash's answer for that address. no_address_selected: the header shows a known placeholder ('Your Address', 'Tu dirección') or the signed-out landing page shows its address form; DoorDash answered for a placeholder location. unknown: the header text is not recognised or absent. Delivery available, reason, estimate and too_far_from_consumer are null unless the basis is selected_address." }, + address_selected: { type: ["boolean", "null"], description: "True for basis selected_address, false for no_address_selected, null for unknown. The address text is never returned." }, + signed_in: { type: "boolean", description: "From the dd_cx_logged_in cookie; a hint only, since the cookie can be stale. Fees and promotions can differ by account." }, + too_far_from_consumer: { type: ["boolean", "null"], description: "DoorDash's own flag that the store is outside delivery range of the selected address. Null unless the basis is selected_address." }, + note: { type: "string" } + } + }, + menu: { + anyOf: [{ type: "null" }, { + type: "object", additionalProperties: false, required: ["menu_id", "name", "display_hours", "menus", "menus_truncated"], + properties: { + menu_id: { type: ["string", "null"], description: "The default menu id; pass it as menuId to the site's add_to_cart tool." }, + name: { type: ["string", "null"] }, + display_hours: { type: ["string", "null"] }, + menus: { + type: "array", maxItems: 20, + items: { + type: "object", additionalProperties: false, required: ["menu_id", "name", "display_hours"], + properties: { menu_id: { type: "string" }, name: { type: ["string", "null"] }, display_hours: { type: ["string", "null"] } } + }, + description: "Every menu the store offers (for example All Day, Lunch), each usable as menuId." + }, + menus_truncated: { type: "boolean", description: "True when the store lists more than 20 menus." } + } + }], + description: "Null for convenience stores." + } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message"], + properties: { + status: { const: "error" }, + error_kind: { enum: ["invalid_input", "wrong_page", "not_found", "login_required", "challenge", "rate_limited", "site_changed", "site_error"] }, + error_message: { type: "string", minLength: 1 } + } + } + ] + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, untrustedContentHint: true } + }, + execute: async (input, context) => { + const ORIGIN = "https://www.doordash.com"; + const QUERY = "query storepageFeed($storeId: ID!, $fulfillmentType: FulfillmentType, $isMerchantPreview: Boolean, $entryPoint: StoreEntryPoint) { storepageFeed(storeId: $storeId, fulfillmentType: $fulfillmentType, isMerchantPreview: $isMerchantPreview, entryPoint: $entryPoint) { storeHeader { id name description timezone priceRange priceRangeDisplayString offersDelivery offersPickup offersGroupOrder offersCatering offersScheduling isConvenience isDashpassPartner address { lat lng city state street } businessTags { name } ratings { numRatings numRatingsDisplayString averageRating isNewlyAdded } deliveryFeeLayout { title subtitle isSurging displayDeliveryFee } asapStatus { displayUnavailableReason { title } } status { delivery { isAvailable minutes unavailableReason isTooFarFromConsumer } pickup { isAvailable minutes unavailableReason } } } mxInfo { operationInfo { operationStatusInfo { description operationStatus } storeOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } doordashOperationHourInfo { operationSchedule { dayOfWeek timeSlotList } } } } menuBook { id name displayOpenHours menuList { id name displayOpenHours } } } }"; + const NOTE = "Delivery availability, estimates and fees are computed by DoorDash for the delivery address selected in this browser session. Without a recognised selected address they describe a placeholder location, not the customer, and are withheld. This tool never changes the address."; + const CHALLENGE = /just a moment|challenge-platform|cf-chl|captcha|turnstile|verify (?:that )?you(?:'re| are) (?:a )?human|access denied|px-captcha/i; + const fail = (kind, message) => ({ status: "error", error_kind: kind, error_message: message }); + const text = (value, max = 300) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; + const id = value => (typeof value === "string" || typeof value === "number") && String(value).trim() ? String(value).trim().slice(0, 30) : null; + const bool = value => typeof value === "boolean" ? value : null; + const number = value => typeof value === "number" && isFinite(value) ? value : (typeof value === "string" && value.trim() && isFinite(Number(value)) ? Number(value) : null); + const days = info => { + const schedule = info && Array.isArray(info.operationSchedule) ? info.operationSchedule : []; + return schedule.filter(d => d && text(d.dayOfWeek, 20)).slice(0, 7).map(d => ({ + day: text(d.dayOfWeek, 20), + slots: (Array.isArray(d.timeSlotList) ? d.timeSlotList : []).map(s => text(s, 60)).filter(Boolean).slice(0, 6) + })); + }; + + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "store_id") || typeof input.store_id !== "string" || !/^[1-9][0-9]{0,11}$/.test(input.store_id)) { + return fail("invalid_input", "store_id must be a string of 1 to 12 digits without a leading zero and is the only accepted field. No request was made."); + } + if (location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.doordash.com/ page in this tab, then call again. Nothing was changed."); + const storeId = input.store_id; + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); + const signals = [AbortSignal.timeout(25000)]; + if (context && context.signal) signals.push(context.signal); + + let response; + let body; + try { + response = await fetch("/graphql/storepageFeed?operation=storepageFeed", { + method: "POST", credentials: "include", signal: AbortSignal.any(signals), + headers: { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, + body: JSON.stringify({ + operationName: "storepageFeed", + variables: { storeId, fulfillmentType: "Delivery", isMerchantPreview: false, entryPoint: "External" }, + query: QUERY + }) + }); + body = await response.text(); + } catch (error) { + return fail("site_error", (error && error.name === "TimeoutError" ? "DoorDash did not answer within 25 seconds." : "The request to DoorDash failed or was cancelled.") + " Nothing was changed."); + } + if (response.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again and do not retry automatically."); + if (response.status === 401) return fail("login_required", "DoorDash refused the request as unauthenticated (HTTP 401). Sign in to DoorDash in this browser and call again."); + let json = null; + try { json = JSON.parse(body); } catch { /* not JSON */ } + if (!json) { + if (CHALLENGE.test(body.slice(0, 4000))) return fail("challenge", "DoorDash is showing a bot check in this browser (HTTP " + response.status + "). A person has to pass it on doordash.com; do not retry automatically."); + return fail("site_error", "DoorDash answered with HTTP " + response.status + " and a response that is not JSON."); + } + if (Array.isArray(json.errors) && json.errors.length) { + const errors = json.errors.map(e => (e && typeof e.message === "string" ? e.message : "") + " " + ((e && e.extensions && e.extensions.code) || "")); + const joined = errors.join(" | "); + if (/not_found|no stores returned/i.test(joined)) return fail("not_found", "DoorDash has no store with id " + storeId + "."); + if (/unauthenticated|unauthorized|not logged in|login required/i.test(joined)) return fail("login_required", "DoorDash requires a signed-in account for this request. Sign in in this browser and call again."); + if (/graphql_validation_failed|cannot query field|unknown (?:type|argument)|bad_user_input|variable "/i.test(joined)) return fail("site_changed", "DoorDash rejected the store query as invalid, so its schema has probably changed: " + joined.slice(0, 200)); + return fail("site_error", "DoorDash returned an error for this store: " + joined.slice(0, 200)); + } + if (!response.ok) return fail("site_error", "DoorDash answered with HTTP " + response.status + "."); + const feed = json.data && json.data.storepageFeed; + const header = feed && feed.storeHeader; + if (!header || typeof header !== "object") return fail("site_changed", "DoorDash's answer has no store header, so its response format has probably changed."); + if (!text(header.name)) return fail("not_found", "DoorDash returned an empty store for id " + storeId + "."); + if (header.id !== undefined && header.id !== null && String(header.id) !== storeId) return fail("site_changed", "DoorDash answered with a different store than requested."); + + const operation = feed.mxInfo && feed.mxInfo.operationInfo || {}; + const status = header.status || {}; + const delivery = status.delivery || {}; + const pickup = status.pickup || {}; + const fee = header.deliveryFeeLayout || {}; + const convenience = header.isConvenience === true; + const menuBook = convenience ? null : feed.menuBook; + const menuList = menuBook && Array.isArray(menuBook.menuList) ? menuBook.menuList.filter(m => m && id(m.id)) : []; + const addressButton = document.querySelector("[data-testid='addressTextButton']"); + const addressText = addressButton ? (addressButton.innerText || "").trim() : ""; + // Placeholder text is localised, so an address is only recognised positively (a street number or ZIP); anything else is unknown. + const basis = addressText + ? (/^(?:your address|tu direcci[oó]n)$/i.test(addressText) ? "no_address_selected" : /\d/.test(addressText) ? "selected_address" : "unknown") + : (document.querySelector("[data-testid='LandingPageAddressSearch']") ? "no_address_selected" : "unknown"); + // Without a selected address DoorDash answers for a placeholder location, so its delivery figures are withheld. + const withheld = basis !== "selected_address"; + const storeAddress = header.address || {}; + + return { + status: "ok", + store_id: storeId, + name: text(header.name, 200), + url: ORIGIN + (convenience ? "/convenience" : "") + "/store/" + storeId + "/", + store_type: convenience ? "convenience" : "restaurant", + description: text(header.description), + cuisines: (Array.isArray(header.businessTags) ? header.businessTags : []).map(t => text(t && t.name, 60)).filter(Boolean).slice(0, 10), + price_range: { level: Number.isInteger(header.priceRange) && header.priceRange >= 1 && header.priceRange <= 4 ? header.priceRange : null, text: text(header.priceRangeDisplayString, 10) }, + rating: { + average: Number.isInteger(header.ratings && header.ratings.numRatings) && header.ratings.numRatings === 0 ? null : number(header.ratings && header.ratings.averageRating), + count: Number.isInteger(header.ratings && header.ratings.numRatings) ? header.ratings.numRatings : null, + count_text: text(header.ratings && header.ratings.numRatingsDisplayString, 30), + is_new: bool(header.ratings && header.ratings.isNewlyAdded) + }, + address: { street: text(storeAddress.street, 200), city: text(storeAddress.city, 100), state: text(storeAddress.state, 50), latitude: number(storeAddress.lat), longitude: number(storeAddress.lng) }, + timezone: text(header.timezone, 60), + offers: { + delivery: bool(header.offersDelivery), pickup: bool(header.offersPickup), group_order: bool(header.offersGroupOrder), + catering: bool(header.offersCatering), scheduling: bool(header.offersScheduling), dashpass_partner: bool(header.isDashpassPartner) + }, + operation_status: text(operation.operationStatusInfo && operation.operationStatusInfo.operationStatus, 40), + operation_text: text(operation.operationStatusInfo && operation.operationStatusInfo.description), + hours: { store: days(operation.storeOperationHourInfo), doordash_orders: days(operation.doordashOperationHourInfo) }, + delivery: { + available: withheld ? null : bool(delivery.isAvailable), + unavailable_reason: withheld ? null : text(delivery.unavailableReason, 100), + unavailable_message: withheld ? null : text(header.asapStatus && header.asapStatus.displayUnavailableReason && header.asapStatus.displayUnavailableReason.title), + estimate_minutes: !withheld && delivery.isAvailable === true ? text(delivery.minutes, 30) : null, + fee_display: withheld ? { title: null, subtitle: null, text: null, is_surging: null } : { title: text(fee.title, 100), subtitle: text(fee.subtitle, 100), text: text(fee.displayDeliveryFee, 100), is_surging: bool(fee.isSurging) } + }, + pickup: { + available: bool(pickup.isAvailable), + unavailable_reason: text(pickup.unavailableReason, 100), + estimate_minutes: pickup.isAvailable === true ? text(pickup.minutes, 30) : null + }, + delivery_context: { + basis, + address_selected: basis === "selected_address" ? true : basis === "no_address_selected" ? false : null, + signed_in: /(?:^|;\s*)dd_cx_logged_in=true(?:;|$)/.test(document.cookie), + too_far_from_consumer: withheld ? null : bool(delivery.isTooFarFromConsumer), + note: NOTE + }, + menu: menuBook ? { + menu_id: id(menuBook.id), + name: text(menuBook.name, 100), + display_hours: text(menuBook.displayOpenHours, 60), + menus: menuList.slice(0, 20).map(m => ({ menu_id: id(m.id), name: text(m.name, 100), display_hours: text(m.displayOpenHours, 60) })), + menus_truncated: menuList.length > 20 + } : null + }; + } +}] diff --git a/domains/doordash.com/remove_cart_item.js b/domains/doordash.com/remove_cart_item.js new file mode 100644 index 00000000..86d4bb79 --- /dev/null +++ b/domains/doordash.com/remove_cart_item.js @@ -0,0 +1,238 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "remove_cart_item", + description: "Remove exactly one existing line from an ordinary personal DoorDash cart using the site's removeCartItem operation. Requires the user's intended expected_account_email, cart_id, line_id (NOT menu item id), expected_store_id and a fresh get_cart expected_revision. Checks the current account, queried cart revision and store immediately before one write; refuses group, bundle, catering, shipping, finalized/unsupported carts and last-line removal. Does not change address, payment, quantity/options of other lines, or place an order. Returns removed only after rereading the open carts proves this line absent and the other lines' identity, quantity and options unchanged (the site may reprice or reorder them). Never retries writes: an uncertain outcome leaves a sessionStorage guard that refuses further removals from this cart until a fresh get_cart revision shows the cart has changed; inspect the cart on the site rather than reinvoking. No live mutation QA has been authorized; mutation evidence is fabricated fixtures only. Call serially; server provides no atomic compare-and-swap, so cross-tab changes remain a race.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["expected_account_email","cart_id","line_id","expected_store_id","expected_revision"], + properties: { + expected_account_email: { + type: "string", + minLength: 3, + maxLength: 254, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$", + description: "The intended account email supplied by the user. Compared with the current session before reading carts or changing anything; never returned. Not a human approval token." + }, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + line_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + expected_store_id: {"type":"string","pattern":"^[1-9][0-9]{0,11}$"}, + expected_revision: {"type":"string","pattern":"^[a-f0-9]{64}$","description":"Fresh get_cart revision of the exact cart. All cart fields must still match. Not approval."} + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: ["status","account_matched","removed","cart_id","line_id","write_attempted","remaining_cart"], + properties: { + status: {"const":"ok"}, + account_matched: {"const":true}, + removed: {"const":true}, + cart_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + line_id: {"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"}, + write_attempted: {"const":true}, + remaining_cart: { + type: "object", + additionalProperties: false, + required: ["cart_id","store_id","store_name","fulfillment_type","group_cart","bundle","catering","merchant_shipping","currency","subtotal_minor","total_minor","lines","fee_lines","revision","truncated"], + properties: { + cart_id: {"type":"string","maxLength":64}, + store_id: {"type":["string","null"],"maxLength":200}, + store_name: {"type":["string","null"],"maxLength":200}, + fulfillment_type: {"enum":["pickup","delivery","unknown"]}, + group_cart: {"type":["boolean","null"]}, + bundle: {"type":["boolean","null"]}, + catering: {"type":["boolean","null"]}, + merchant_shipping: {"type":["boolean","null"]}, + currency: {"type":["string","null"],"maxLength":10}, + subtotal_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + lines: { + type: "array", + maxItems: 100, + items: { + type: "object", + additionalProperties: false, + required: ["line_id","item_id","name","quantity","unit_price_minor","total_minor","price_display","options"], + properties: { + line_id: {"type":"string","maxLength":64}, + item_id: {"type":["string","null"],"maxLength":200}, + name: {"type":["string","null"],"maxLength":200}, + quantity: {"type":["number","null"]}, + unit_price_minor: {"type":["integer","null"]}, + total_minor: {"type":["integer","null"]}, + price_display: {"type":["string","null"],"maxLength":200}, + options: { + type: "array", + maxItems: 50, + items: { + type: "object", + additionalProperties: false, + required: ["option_id","name","quantity","price_minor"], + properties: {"option_id":{"type":"string","maxLength":64},"name":{"type":["string","null"],"maxLength":200},"quantity":{"type":["number","null"]},"price_minor":{"type":["integer","null"]}} + } + } + } + } + }, + fee_lines: { + type: "array", + maxItems: 30, + items: { + type: "object", + additionalProperties: false, + required: ["label","amount_minor","display"], + properties: {"label":{"type":["string","null"],"maxLength":200},"amount_minor":{"type":["integer","null"]},"display":{"type":["string","null"],"maxLength":200}} + } + }, + revision: {"type":"string","pattern":"^[a-f0-9]{64}$"}, + truncated: {"type":"boolean"} + } + } + } + }, + { + type: "object", + additionalProperties: false, + required: ["status","error_kind","error_message","write_attempted"], + properties: { + status: {"const":"error"}, + error_kind: {"enum":["invalid_input","wrong_page","login_required","wrong_account","blocked","rate_limited","site_changed","site_error","cancelled","not_found","stale_state","busy","outcome_unknown"]}, + error_message: {"type":"string","maxLength":400}, + write_attempted: {"type":"boolean"} + } + } + ] + }, + annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"untrustedContentHint":true} + }, + execute: async (input, context) => { + const fail = (error_kind, error_message, write_attempted = false) => ({ status: "error", error_kind, error_message, write_attempted }); + const allowed = ["expected_account_email","cart_id","line_id","expected_store_id","expected_revision"]; + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !allowed.includes(k)) || allowed.some(k => typeof input[k] !== "string") || input.expected_account_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.expected_account_email)) return fail("invalid_input", "Supply only the documented fields, including the intended account email. No request was made."); + if (![input.cart_id, input.line_id].every(v => /^[A-Za-z0-9_-]{1,64}$/.test(v)) || !/^[1-9][0-9]{0,11}$/.test(input.expected_store_id) || !/^[a-f0-9]{64}$/.test(input.expected_revision)) return fail("invalid_input", "Invalid cart, line, store or revision. No request was made."); + if (location.origin !== "https://www.doordash.com") return fail("wrong_page", "Open www.doordash.com in this tab. Nothing was changed."); + if (context?.signal?.aborted) return fail("cancelled", "Cancelled before any request."); + const signals = [AbortSignal.timeout(25000)]; + if (context?.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice(11); + let writeAttempted = false; + const request = async (operationName, query, variables) => { + if (signal.aborted) throw fail(writeAttempted ? "outcome_unknown" : "cancelled", writeAttempted ? "The write outcome is unknown. Inspect the cart manually; do not retry automatically." : "Cancelled before the request.", writeAttempted); + let r, raw; + try { + r = await fetch("/graphql/" + operationName + "?operation=" + operationName, { method: "POST", credentials: "include", signal, headers: { "content-type": "application/json", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }, body: JSON.stringify({ operationName, variables, query }) }); + raw = await r.text(); + } catch { throw fail(writeAttempted ? "outcome_unknown" : "site_error", writeAttempted ? "The write outcome is unknown. Inspect the cart manually; do not retry automatically." : "DoorDash did not answer or the read was cancelled.", writeAttempted); } + if (writeAttempted && !r.ok) throw fail("outcome_unknown", "DoorDash did not confirm the cart write. Inspect manually; do not retry automatically.", true); + if (r.status === 401) throw fail("login_required", "Sign in to the intended DoorDash account manually."); + if (r.status === 429) throw fail("rate_limited", "DoorDash is rate limiting this session. Do not retry automatically."); + if (r.status === 403) throw fail("blocked", "DoorDash denied this request. Resolve access manually; do not bypass or retry automatically."); + if (new TextEncoder().encode(raw).byteLength > 2000000) throw fail(writeAttempted ? "outcome_unknown" : "site_changed", "DoorDash returned an oversized response.", writeAttempted); + let j; + try { j = JSON.parse(raw); } catch { throw fail(writeAttempted ? "outcome_unknown" : "blocked", writeAttempted ? "The cart write has no readable confirmation. Inspect manually; do not retry." : "DoorDash returned a non-JSON page, possibly a challenge. Resolve it manually.", writeAttempted); } + if (j.errors?.length) { + const codes = j.errors.map(e => String(e?.extensions?.code || "")).join(" "); + const kind = writeAttempted ? "outcome_unknown" : /UNAUTHENTICATED|UNAUTHORIZED/.test(codes) ? "login_required" : /PERMISSION_DENIED|FORBIDDEN/.test(codes) ? "blocked" : /RATE_LIMIT/.test(codes) ? "rate_limited" : "site_changed"; + throw fail(kind, writeAttempted ? "DoorDash did not confirm the cart write. Inspect manually; do not retry automatically." : "DoorDash rejected the query. No private server error text is returned.", writeAttempted); + } + if (!r.ok || !j.data || typeof j.data !== "object") throw fail(writeAttempted ? "outcome_unknown" : "site_changed", "DoorDash's response is incomplete.", writeAttempted); + return j.data; + }; + const accountQuery = "query consumerBucketing { consumer { email isGuest } }"; + const cartQuery = "query listDetailedCarts($input: ListDetailedCartsInput!) { listDetailedCarts(input: $input) { detailedCarts { cart { id groupCart isBundle isCatering isMerchantShipping isPrescriptionDelivery isConsumerPickup currencyCode subtotal total restaurant { id name } orders { id isSubCartFinalized orderItems { id quantity singlePrice priceOfTotalQuantity priceDisplayString cartItemStatusType nestedOptions specialInstructions continuousQuantity purchaseType isPrescriptionItem options { id name quantity price } item { id name storeId } } lineItems { label finalMoney { unitAmount displayString } } } } } } }"; + const text = (v, n = 200) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; + const ident = v => (typeof v === "string" || Number.isSafeInteger(v)) && /^[A-Za-z0-9_-]{1,64}$/.test(String(v)) ? String(v) : null; + const integer = v => Number.isSafeInteger(v) ? v : null; + const bool = v => typeof v === "boolean" ? v : null; + const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v; + const revision = async c => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical(c)))))].map(b => b.toString(16).padStart(2, "0")).join(""); + const readAccount = async () => { + const data = await request("consumerBucketing", accountQuery, {}); + const c = data.consumer; + if (!c || c.isGuest === true || !text(c.email)) throw fail("login_required", "A non-guest signed-in account is required. Sign in manually."); + if (c.isGuest !== false) throw fail("site_changed", "DoorDash did not confirm the account type."); + if (c.email.trim().toLowerCase() !== input.expected_account_email.toLowerCase()) throw fail("wrong_account", "This browser is signed in to a different account. Nothing was changed."); + return c; + }; + const readCarts = async () => { + const data = await request("listDetailedCarts", cartQuery, { input: {} }); + const list = data.listDetailedCarts?.detailedCarts; + if (!Array.isArray(list)) throw fail("site_changed", "DoorDash did not return its open-cart list."); + return list.map(d => d?.cart); + }; + const project = async c => { + if (!c || !ident(c.id) || !Array.isArray(c.orders) || c.orders.some(o => !o || !Array.isArray(o.orderItems))) throw fail("site_changed", "An open cart has an unsupported shape."); + const rawLines = c.orders.flatMap(o => o.orderItems); + if (rawLines.some(l => !l || !ident(l.id))) throw fail("site_changed", "A cart line has no usable id."); + const rawFees = c.orders.flatMap(o => Array.isArray(o.lineItems) ? o.lineItems : []); + return { cart_id: ident(c.id), store_id: ident(c.restaurant?.id), store_name: text(c.restaurant?.name), fulfillment_type: c.isConsumerPickup === true ? "pickup" : c.isConsumerPickup === false ? "delivery" : "unknown", group_cart: bool(c.groupCart), bundle: bool(c.isBundle), catering: bool(c.isCatering), merchant_shipping: bool(c.isMerchantShipping), currency: text(c.currencyCode, 10), subtotal_minor: integer(c.subtotal), total_minor: integer(c.total), lines: rawLines.slice(0, 100).map(l => ({ line_id: ident(l.id), item_id: ident(l.item?.id), name: text(l.item?.name), quantity: typeof l.quantity === "number" && Number.isFinite(l.quantity) ? l.quantity : null, unit_price_minor: integer(l.singlePrice), total_minor: integer(l.priceOfTotalQuantity), price_display: text(l.priceDisplayString), options: (Array.isArray(l.options) ? l.options : []).slice(0, 50).map(o => ({ option_id: ident(o?.id) || "unknown", name: text(o?.name), quantity: typeof o?.quantity === "number" && Number.isFinite(o.quantity) ? o.quantity : null, price_minor: integer(o?.price) })) })), fee_lines: rawFees.slice(0, 30).map(l => ({ label: text(l?.label), amount_minor: integer(l?.finalMoney?.unitAmount), display: text(l?.finalMoney?.displayString) })), revision: await revision(c), truncated: rawLines.length > 100 || rawFees.length > 30 || rawLines.some(l => Array.isArray(l.options) && l.options.length > 50) }; + }; + + try { + await readAccount(); + const carts = await readCarts(); + const matches = carts.filter(c => c?.id === input.cart_id); + if (matches.length !== 1) return fail("not_found", "The exact open cart is not present in this account."); + const before = matches[0]; + if (String(before.restaurant?.id) !== input.expected_store_id) return fail("stale_state", "The cart belongs to another store. Nothing was changed."); + if (before.groupCart !== false || before.isBundle !== false || before.isCatering !== false || before.isMerchantShipping !== false || before.isPrescriptionDelivery !== false || before.orders?.length !== 1 || before.orders[0]?.isSubCartFinalized !== false) return fail("blocked", "Only ordinary personal, non-bundle, non-catering, non-shipping carts with one order are supported."); + const projectedBefore = await project(before); + if (projectedBefore.truncated || new TextEncoder().encode(JSON.stringify(projectedBefore)).byteLength > 170000) return fail("blocked", "The complete cart exceeds safe bounds. Nothing was changed."); + const lines = before.orders[0].orderItems; + if (!Array.isArray(lines) || lines.length < 2 || lines.length > 100 || lines.some(l => !l || !ident(l.id))) return fail("blocked", "Last-line removal or an incomplete/unsupported cart is not supported."); + if (lines.some(l => l.isPrescriptionItem !== false || !ident(l.item?.id) || String(l.item?.storeId) !== input.expected_store_id || !Array.isArray(l.options) || l.options.some(o => !o || !ident(o.id)) || typeof l.quantity !== "number" || !Number.isFinite(l.quantity) || l.quantity <= 0)) return fail("blocked", "Prescription, incomplete or conflicting cart lines are not supported. Nothing was changed."); + if (lines.filter(l => String(l.id) === input.line_id).length !== 1) return fail("not_found", "That exact line is not present exactly once in this cart."); + if (await revision(before) !== input.expected_revision) return fail("stale_state", "The cart changed after get_cart. Read it again; no write was sent."); + await readAccount(); + if (signal.aborted) return fail("cancelled", "Cancelled before the cart write."); + const key = "doordash.com.remove_cart_item.pending.v1:" + input.cart_id; + let lock; + const owned = () => { try { return JSON.parse(sessionStorage.getItem(key))?.lock === lock; } catch { return false; } }; + try { + const pending = sessionStorage.getItem(key); + if (pending) { + // The guard records the cart revision the earlier write started from. A fresh expected_revision that differs + // shows the caller re-read the cart after that write's outcome became visible, so the guard is spent. + let started = null; + try { started = JSON.parse(pending).revision; } catch { /* unreadable guard stays in force */ } + if (typeof started !== "string" || started === input.expected_revision) return fail("busy", "A previous write to this cart has an uncertain or in-flight outcome and the cart has not changed since. Check the cart on the site; do not retry."); + sessionStorage.removeItem(key); + } + lock = crypto.randomUUID(); + sessionStorage.setItem(key, JSON.stringify({ lock, revision: input.expected_revision })); + if (!owned()) return fail("busy", "Another cart write is in progress."); + } catch { return fail("blocked", "The duplicate-write guard is unavailable; no write was sent."); } + if (signal.aborted) { sessionStorage.removeItem(key); return fail("cancelled", "Cancelled before the cart write."); } + writeAttempted = true; + const result = await request("removeCartItem", "mutation removeCartItem($cartId: ID!, $itemId: ID!, $returnCartFromOrderService: Boolean, $monitoringContext: MonitoringContextInput, $cartContext: CartContextInput) { removeCartItemV2(cartId: $cartId, itemId: $itemId, returnCartFromOrderService: $returnCartFromOrderService, monitoringContext: $monitoringContext, cartContext: $cartContext) { id } }", { cartId: input.cart_id, itemId: input.line_id, returnCartFromOrderService: false, monitoringContext: { isGroup: false }, cartContext: { deleteBundleCarts: false } }); + if (result.removeCartItemV2?.id !== input.cart_id) return fail("outcome_unknown", "DoorDash did not acknowledge the exact cart. Inspect manually; do not retry.", true); + await readAccount(); + const afterList = await readCarts(); + const afterMatches = afterList.filter(c => c?.id === input.cart_id); + if (afterMatches.length !== 1) return fail("outcome_unknown", "The cart could not be observed after the write. Inspect manually; do not retry.", true); + const after = afterMatches[0]; + // The site may reprice the remaining lines (promotions, bundles); their identity, quantity and options must not change. + const repriced = new Map((Array.isArray(after.orders?.[0]?.orderItems) ? after.orders[0].orderItems : []).map(l => [String(l?.id), l])); + const kept = l => { const now = repriced.get(String(l.id)); return { ...l, singlePrice: now?.singlePrice, priceOfTotalQuantity: now?.priceOfTotalQuantity, priceDisplayString: now?.priceDisplayString }; }; + const expected = { ...before, subtotal: after.subtotal, total: after.total, orders: before.orders.map(o => ({ ...o, orderItems: o.orderItems.filter(l => String(l.id) !== input.line_id).map(kept), lineItems: after.orders?.[0]?.lineItems })) }; + // The order of the remaining lines is not part of their identity. + const byLineId = c => ({ ...c, orders: Array.isArray(c.orders) ? c.orders.map(o => (o && Array.isArray(o.orderItems) ? { ...o, orderItems: [...o.orderItems].sort((x, y) => String(x?.id).localeCompare(String(y?.id))) } : o)) : c.orders }); + if (JSON.stringify(canonical(byLineId(expected))) !== JSON.stringify(canonical(byLineId(after)))) return fail("outcome_unknown", "The exact removal with unchanged other lines was not confirmed. Inspect manually; do not retry.", true); + const remaining = await project(after); + if (signal.aborted) return fail("outcome_unknown", "Cancelled before the final cart result was returned. Inspect manually; do not retry.", true); + if (remaining.truncated || new TextEncoder().encode(JSON.stringify(remaining)).byteLength > 170000) return fail("outcome_unknown", "The remaining cart exceeds safe observation bounds. Inspect manually; do not retry.", true); + if (owned()) sessionStorage.removeItem(key); + return { status: "ok", account_matched: true, removed: true, cart_id: input.cart_id, line_id: input.line_id, write_attempted: true, remaining_cart: remaining }; + } catch (error) { + if (writeAttempted) return fail("outcome_unknown", "The exact cart write postcondition was not observed. Inspect manually; do not retry automatically.", true); + return error?.status === "error" ? error : fail("site_changed", "DoorDash returned an unsupported cart or account shape. Nothing was changed."); + } + } +}] diff --git a/domains/doordash.com/set_delivery_address.js b/domains/doordash.com/set_delivery_address.js new file mode 100644 index 00000000..21b57173 --- /dev/null +++ b/domains/doordash.com/set_delivery_address.js @@ -0,0 +1,144 @@ +[{ + kind: "page", + match: { url_patterns: ["https://www.doordash.com/*"] }, + tool: { + name: "set_delivery_address", + description: "Set the delivery address of a signed-out DoorDash visit, as the header's address picker does: it looks the address up in DoorDash's address autocomplete, chooses the suggestion you name (the first by default, as the picker lists them) and makes it the visit's delivery address with the same requests the picker sends. Success is confirmed by DoorDash's answer, which states the address it now delivers to and must have the chosen suggestion's street, city and ZIP code. Store fees, delivery estimates, search results and get_store then use that address, but pages already open keep showing the old one until they load again: the site itself opens /home after an address is chosen, so navigate before reading, then list the tools again. Returns the suggestions when none matches the request or the chosen number is out of range; never edits saved account addresses: it asks DoorDash whether the visit is signed out before changing anything and refuses when it is not (use save_address then). Only call it with an address the user gave.", + inputSchema: { + type: "object", additionalProperties: false, required: ["address"], + properties: { + address: { type: "string", minLength: 3, maxLength: 200, description: "The address as you would type it into the picker, e.g. 'Times Square, New York, NY 10036'." }, + suggestion: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Which autocomplete suggestion to choose, counting from 1 in the order DoorDash lists them; returned suggestions show that order." } + } + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", additionalProperties: false, required: ["status", "address", "suggestions", "next_step"], + properties: { + status: { const: "ok" }, + address: { + type: "object", additionalProperties: false, required: ["printable", "street", "city", "state", "zip_code", "submarket"], + description: "The delivery address as DoorDash now reports it for this visit.", + properties: { + printable: { type: "string", maxLength: 200 }, street: { type: ["string", "null"], maxLength: 200 }, city: { type: ["string", "null"], maxLength: 200 }, state: { type: ["string", "null"], maxLength: 20 }, + zip_code: { type: ["string", "null"], maxLength: 20 }, submarket: { type: ["string", "null"], maxLength: 200 } + } + }, + suggestions: { type: "array", maxItems: 10, items: { type: "string", maxLength: 200 }, description: "Every suggestion the autocomplete offered, in its order." }, + next_step: { type: "string", maxLength: 500 } + } + }, + { + type: "object", additionalProperties: false, required: ["status", "error_kind", "error_message", "suggestions"], + properties: { + status: { const: "error" }, + error_kind: { enum: ["invalid_input", "wrong_page", "signed_in", "address_not_found", "address_mismatch", "rate_limited", "site_changed", "site_error"] }, + error_message: { type: "string", maxLength: 500 }, + suggestions: { type: "array", maxItems: 10, items: { type: "string", maxLength: 200 } } + } + } + ] + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, untrustedContentHint: true } + }, + execute: async (input, context) => { + const ORIGIN = "https://www.doordash.com"; + const MUTATION = "mutation addConsumerAddressV2($lat: Float!, $lng: Float!, $city: String!, $state: String!, $zipCode: String!, $printableAddress: String!, $shortname: String!, $googlePlaceId: String!, $addressId: String) {\n addConsumerAddressV2(\n lat: $lat\n lng: $lng\n city: $city\n state: $state\n zipCode: $zipCode\n printableAddress: $printableAddress\n shortname: $shortname\n googlePlaceId: $googlePlaceId\n addressId: $addressId\n ) {\n defaultAddress {\n id\n street\n city\n state\n zipCode\n printableAddress\n submarket\n __typename\n }\n isGuest\n __typename\n }\n}\n"; + const fail = (kind, message, suggestions = []) => ({ status: "error", error_kind: kind, error_message: message, suggestions }); + const text = (value, max = 200) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null; + + if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => k !== "address" && k !== "suggestion")) return fail("invalid_input", "Only address and suggestion are accepted. Nothing was changed."); + const address = typeof input.address === "string" ? input.address.trim().replace(/\s+/g, " ") : ""; + if (address.length < 3 || address.length > 200) return fail("invalid_input", "address must be 3 to 200 characters. Nothing was changed."); + const choice = input.suggestion === undefined ? 1 : input.suggestion; + if (!Number.isInteger(choice) || choice < 1 || choice > 10) return fail("invalid_input", "suggestion must be an integer from 1 to 10. Nothing was changed."); + if (location.origin !== ORIGIN) return fail("wrong_page", "Open a https://www.doordash.com/ page in this tab, then call again. Nothing was changed."); + if (/(?:^|;\s*)dd_cx_logged_in=true(?:;|$)/.test(document.cookie)) return fail("signed_in", "This browser is signed in to DoorDash, where choosing an address saves it to the account; use save_address instead. Nothing was changed."); + + const signals = [AbortSignal.timeout(25000)]; + if (context && context.signal) signals.push(context.signal); + const signal = AbortSignal.any(signals); + const csrf = (document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("csrf_token=")) || "").slice("csrf_token=".length); + const graphql = { "content-type": "application/json", accept: "*/*", "x-csrftoken": csrf, "x-channel-id": "marketplace", "x-experience-id": "doordash" }; + const gateway = { accept: "application/json", "x-experience-id": "doordash", "x-unified-gateway-generated-source": "v1" }; + const call = async (path, init) => { + let response, body; + try { + response = await fetch(path, { credentials: "include", signal, ...init }); + body = await response.text(); + } catch (error) { + return { error: error && error.name === "TimeoutError" ? "DoorDash did not answer within 25 seconds." : "The request to DoorDash failed or was cancelled." }; + } + if (response.status === 429) return { status: 429 }; + let json = null; + try { json = JSON.parse(body); } catch { /* not JSON */ } + return { status: response.status, json }; + }; + + // The cookie can be missing on a signed-in visit, and for an account the mutation below saves the address there. + const visitor = await call("/graphql/consumerBucketing?operation=consumerBucketing", { + method: "POST", headers: graphql, + body: JSON.stringify({ operationName: "consumerBucketing", variables: {}, query: "query consumerBucketing { consumer { isGuest } }" }) + }); + if (visitor.error) return fail("site_error", visitor.error + " Nothing was changed."); + if (visitor.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed."); + const isGuest = visitor.json && visitor.json.data && visitor.json.data.consumer ? visitor.json.data.consumer.isGuest : undefined; + if (isGuest === false) return fail("signed_in", "This browser is signed in to DoorDash, where choosing an address saves it to the account; use save_address instead. Nothing was changed."); + if (visitor.status !== 200 || isGuest !== true) return fail("site_changed", "DoorDash did not confirm this is a signed-out visit (HTTP " + visitor.status + "). Nothing was changed."); + + const found = await call("/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address=" + encodeURIComponent(address), { headers: gateway }); + if (found.error) return fail("site_error", found.error + " Nothing was changed."); + if (found.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed."); + if (found.status !== 200 || !found.json || !Array.isArray(found.json.predictions)) return fail("site_changed", "DoorDash's address autocomplete answered HTTP " + found.status + " without its predictions list. Nothing was changed."); + const predictions = found.json.predictions.filter(p => p && text(p.formatted_address) && text(p.source_place_id, 500)).slice(0, 10); + const suggestions = predictions.map(p => text(p.formatted_address)); + if (!predictions.length) return fail("address_not_found", "DoorDash's address autocomplete has no suggestion for '" + address.slice(0, 80) + "'. Try a street address with city and ZIP. Nothing was changed."); + if (choice > predictions.length) return fail("address_not_found", "DoorDash offered " + predictions.length + " suggestion(s), so there is no suggestion " + choice + ". Nothing was changed.", suggestions); + const picked = predictions[choice - 1]; + + const created = await call("/unified-gateway/geo-intelligence/v2/address/get-or-create", { + method: "POST", headers: { ...gateway, "content-type": "application/json" }, + body: JSON.stringify({ address_identifier: { _type: "source_place_id_request", source_place_id: picked.source_place_id } }) + }); + if (created.error) return fail("site_error", created.error + " Nothing was changed.", suggestions); + if (created.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. Nothing was changed.", suggestions); + const place = created.json && created.json.address; + const lat = place && Number(place.lat), lng = place && Number(place.lng); + if (created.status !== 200 || !place || !text(place.id, 40) || !text(place.source_place_id, 200) || !isFinite(lat) || !isFinite(lng) || !text(place.locality) || !text(place.administrative_area_level1) || !text(place.postal_code)) { + return fail("site_changed", "DoorDash did not resolve the chosen suggestion to an address (HTTP " + created.status + "). Nothing was changed.", suggestions); + } + const segments = Array.isArray(place.formatted_address_segmented) ? place.formatted_address_segmented : []; + const shortname = text(place.formatted_address_short) || text(place.street_address) || text(segments[0]); + const printable = text(segments[1]) || text(place.formatted_address); + + const saved = await call("/graphql/addConsumerAddressV2?operation=addConsumerAddressV2", { + method: "POST", + headers: graphql, + body: JSON.stringify({ + operationName: "addConsumerAddressV2", + variables: { googlePlaceId: place.source_place_id, printableAddress: printable, lat, lng, city: place.locality, state: place.administrative_area_level1, zipCode: place.postal_code, shortname, addressId: String(place.id) }, + query: MUTATION + }) + }); + if (saved.error) return fail("site_error", saved.error + " The address may or may not have been set; reload the page and check the header before calling again.", suggestions); + if (saved.status === 429) return fail("rate_limited", "DoorDash is rate limiting requests from this browser; wait before calling again. The address was not confirmed.", suggestions); + const result = saved.json && saved.json.data && saved.json.data.addConsumerAddressV2; + const current = result && result.defaultAddress; + if (saved.status !== 200 || !current || !text(current.printableAddress) || (saved.json.errors && saved.json.errors.length)) { + return fail("site_error", "DoorDash did not confirm the address (HTTP " + saved.status + "). Reload the page and check the header before calling again.", suggestions); + } + if (result.isGuest === false) return fail("signed_in", "DoorDash answered for a signed-in account, so the address may now be saved there; check the account's addresses. Use save_address for signed-in visits.", suggestions); + const same = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase(); + if (!same(current.zipCode, place.postal_code) || !same(current.city, place.locality) || (text(place.street_address) && !same(current.street, place.street_address))) { + return fail("address_mismatch", "DoorDash now reports '" + text(current.printableAddress, 120) + "' as the delivery address, not the chosen suggestion. Reload the page and check the header before calling again.", suggestions); + } + return { + status: "ok", + address: { printable: text(current.printableAddress), street: text(current.street), city: text(current.city), state: text(current.state, 20), zip_code: text(current.zipCode, 20), submarket: text(current.submarket) }, + suggestions, + next_step: "Navigate (for example to https://www.doordash.com/home, where the site goes after an address is chosen, or to a search page), then list the tools again; pages already open still show the previous address." + }; + } +}] diff --git a/registry.json b/registry.json index 0bc2fb93..e9c979c0 100644 --- a/registry.json +++ b/registry.json @@ -1392,6 +1392,57 @@ "qa_agent": null } }, + { + "domain": "doordash.com", + "tool": "get_cart", + "file": "domains/doordash.com/get_cart.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Account-checked open-cart read with bounded lines/options, provisional currency/fee/totals, queried revision and explicit header/default-region context. Independent Kernel and local-extension success/failure QA passed on final source; populated success is fixture-only. Earlier source read an authorized empty live cart. No address, payment or checkout changes.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } + }, + { + "domain": "doordash.com", + "tool": "get_checkout_summary", + "file": "domains/doordash.com/get_checkout_summary.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "qa_passed", + "last_verified": "2026-09-30", + "notes": "Read-only exact-cart preparation with provisional fees/totals, explicit address-context limitations and a manual checkout URL. Does not load checkout or execute orders/payment; ready_for_order is always false because no trusted approval enforcement exists. Independent Kernel/local-extension success/failure QA passed; populated success is fixture-only.", + "provenance": { + "qa_agent": "agt_e3cd254315c78d609b12" + } + }, + { + "domain": "doordash.com", + "tool": "get_store", + "file": "domains/doordash.com/get_store.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "draft", + "last_verified": null, + "notes": "Page tool. Store details the site's native menu tools lack: hours, address, service modes, open status, and delivery and pickup availability with estimate range and the site's own fee strings, plus the menus with the menu_id the native add_to_cart needs. Delivery figures are meaningful only for a selected address, so the output states its basis (selected address, none selected, or unknown) and returns delivery availability, estimate and fee strings only when an address is positively recognised; it never selects or changes an address and returns neither address text nor customer distance. One same-origin read of the site's storepageFeed query, no navigation; works signed out. Independent QA (two rounds, 2026-09-29) signed off on earlier source on the Kernel runtime and a local Chromium extension; the fee strings have since been withheld without a recognised address too, since they change with it (live on 2026-10-23, signed out, a store read '$0 delivery fee, first order' with no address and '--' once a distant address was set), so it needs fresh independent QA. The selected-address path was live-checked only by the author, and the 401, 429, challenge and site-changed branches are stub-driven only.", + "provenance": { + "qa_agent": null + } + }, { "domain": "doordash.com", "tool": "login", @@ -1426,6 +1477,20 @@ "qa_agent": null } }, + { + "domain": "doordash.com", + "tool": "remove_cart_item", + "file": "domains/doordash.com/remove_cart_item.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "draft", + "last_verified": null, + "notes": "Exact non-last ordinary personal cart-line removal with account/store/revision, complete non-prescription line checks, an uncertain-write guard and observed postconditions. One attempt, no automatic retries. The guard refuses further removals from the cart until a fresh get_cart revision shows the cart changed; the postcondition lets the site reprice or reorder the remaining lines but not change their identity, quantity or options. Independent Kernel/local-extension QA passed on 2026-09-30 for earlier source; the guard and postcondition changed since and need fresh independent QA. All mutation success is fixture-only, never live-verified. Cross-tab races are not atomically excluded; final order/payment execution is absent." + }, { "domain": "doordash.com", "tool": "save_address", @@ -1443,6 +1508,20 @@ "qa_agent": null } }, + { + "domain": "doordash.com", + "tool": "set_delivery_address", + "file": "domains/doordash.com/set_delivery_address.js", + "kind": "page", + "namespace": "doordash.com", + "url_patterns": [ + "https://www.doordash.com/*" + ], + "url_patterns_reason": "Narrowed to www.doordash.com, the consumer marketplace: these tools call its same-origin GraphQL endpoint with the page's session; other doordash.com hosts are separate products such as the merchant portal and help center.", + "status": "draft", + "last_verified": null, + "notes": "Signed-out visits only: refuses when the dd_cx_logged_in cookie is set or DoorDash's consumer query reports a non-guest visitor, before any address request (save_address covers accounts). Sends the address picker's own three requests (autocomplete, get-or-create, addConsumerAddressV2) and reports success only when DoorDash's answer has the chosen suggestion's street, city and ZIP code; does not navigate. Draft, no independent QA. Live in a signed-out Kernel browser: on 2026-10-21 'Times Square, New York, NY 10036' set 4 Times Sq and the header showed it after loading /home, and an address with no suggestions returned address_not_found; on 2026-10-22 the source with the guest check and street/city/ZIP confirmation set the same address, 350 5th Ave, 1600 Amphitheatre Pkwy and 233 S Wacker Dr, each confirmed by DoorDash's answer." + }, { "domain": "etsy.com", "tool": "add_to_cart", diff --git a/tests/doordash-cart.test.mjs b/tests/doordash-cart.test.mjs new file mode 100644 index 00000000..a69d5645 --- /dev/null +++ b/tests/doordash-cart.test.mjs @@ -0,0 +1,314 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import { webcrypto } from "node:crypto"; + +// All account, cart and line values are fabricated. No tests contact DoorDash. +const sources = Object.fromEntries(["get_cart", "get_checkout_summary", "remove_cart_item"].map(name => [name, readFileSync(new URL(`../domains/doordash.com/${name}.js`, import.meta.url), "utf8")])); +const plain = value => JSON.parse(JSON.stringify(value)); +const email = "shopper@example.invalid"; +const account = () => ({ email, isGuest: false, defaultAddress: { city: "Testville", state: "ZZ", zipCode: "00000", countryCode: "US", entryCode: "secret-entry" } }); +const item = (id, quantity = 1) => ({ id, quantity, singlePrice: 700, priceOfTotalQuantity: 700 * quantity, priceDisplayString: "$7.00", cartItemStatusType: "CART_ITEM_STATUS_TYPE_UNSPECIFIED", isPrescriptionItem: false, options: [{ id: "extra-1", name: "Tofu", quantity: 1, price: 100 }], item: { id: "menu-" + id, name: "Fabricated bowl", storeId: "1001" } }); +const cart = () => ({ id: "cart-1", groupCart: false, isBundle: false, isCatering: false, isMerchantShipping: false, isPrescriptionDelivery: false, isConsumerPickup: false, currencyCode: "USD", subtotal: 1400, total: 1700, restaurant: { id: "1001", name: "Fabricated Kitchen" }, orders: [{ id: "order-1", isSubCartFinalized: false, orderItems: [item("line-1"), item("line-2")], lineItems: [{ label: "Service fee", finalMoney: { unitAmount: 300, displayString: "$3.00" } }] }] }); + +function conforms(s, v) { + if (s.oneOf && s.oneOf.filter(x => conforms(x, v)).length !== 1) return false; + if (s.anyOf && !s.anyOf.some(x => conforms(x, v))) return false; + if ("const" in s && s.const !== v || s.enum && !s.enum.includes(v)) return false; + if (s.type && ![s.type].flat().some(t => t === "null" ? v === null : t === "integer" ? Number.isInteger(v) : t === "array" ? Array.isArray(v) : t === "object" ? v && typeof v === "object" && !Array.isArray(v) : typeof v === t)) return false; + if (v && typeof v === "object" && !Array.isArray(v)) { + if (s.required?.some(k => !Object.hasOwn(v, k))) return false; + if (s.additionalProperties === false && Object.keys(v).some(k => !Object.hasOwn(s.properties || {}, k))) return false; + if (Object.entries(s.properties || {}).some(([k, x]) => k in v && !conforms(x, v[k]))) return false; + } + if (Array.isArray(v) && (v.length > (s.maxItems ?? Infinity) || s.items && !v.every(x => conforms(s.items, x)))) return false; + if (typeof v === "string" && (v.length < (s.minLength ?? 0) || v.length > (s.maxLength ?? Infinity) || s.pattern && !new RegExp(s.pattern).test(v))) return false; + return true; +} + +function harness(options = {}) { + const calls = [], store = new Map(); + let carts = options.carts ?? [cart()], consumer = options.consumer === undefined ? account() : options.consumer; + const fetch = async (url, init) => { + const body = JSON.parse(init.body); + calls.push({ url, ...body, headers: init.headers, signal: init.signal }); + let data; + const custom = await options.respond?.(body, { carts, consumer, calls }); + if (custom) return { status: custom.status ?? 200, ok: (custom.status ?? 200) < 400, text: async () => custom.raw ?? JSON.stringify(custom.json) }; + if (body.operationName === "consumerBucketing") data = { consumer }; + else if (body.operationName === "listDetailedCarts") data = { listDetailedCarts: { detailedCarts: carts.map(c => ({ cart: c })) } }; + else if (body.operationName === "removeCartItem") { + if (options.writeError) throw new Error("transport-secret"); + if (!options.noRemoval) { + const c = carts.find(c => c.id === body.variables.cartId); + c.orders[0].orderItems = c.orders[0].orderItems.filter(l => l.id !== body.variables.itemId); + c.subtotal = 700; c.total = 1000; + options.afterWrite?.(c); + } + data = { removeCartItemV2: options.nullAck ? null : { id: body.variables.cartId } }; + } else throw new Error("unexpected operation"); + return { status: 200, ok: true, text: async () => JSON.stringify({ data }) }; + }; + const context = { location: { origin: options.origin ?? "https://www.doordash.com", pathname: "/home" }, document: { cookie: "csrf_token=fabricated-token; dd_cx_logged_in=true", querySelector: selector => selector.includes("addressTextButton") && options.header !== null ? { innerText: options.header ?? "1 Fabricated Street" } : null }, fetch, crypto: options.crypto ?? webcrypto, TextEncoder, AbortSignal, sessionStorage: { getItem: k => options.storageError ? (() => { throw Error("blocked"); })() : store.get(k) ?? null, setItem: (k, v) => store.set(k, v), removeItem: k => store.delete(k) } }; + const definitions = Object.fromEntries(Object.entries(sources).map(([name, source]) => [name, runInNewContext(`(${source})`, context)[0]])); + const run = async (name, input, signal) => { + const result = plain(await definitions[name].execute(input, { signal })); + assert.ok(conforms(definitions[name].tool.outputSchema, result), JSON.stringify(result)); + return result; + }; + return { run, calls, store, definitions, getCarts: () => carts, setConsumer: c => { consumer = c; } }; +} +const read = { expected_account_email: email }; +async function removalInput(h) { + const out = await h.run("get_cart", read); + assert.equal(out.status, "ok"); + return { ...read, cart_id: "cart-1", line_id: "line-1", expected_store_id: "1001", expected_revision: out.carts[0].revision }; +} +const writes = h => h.calls.filter(c => c.operationName === "removeCartItem"); + +for (const name of Object.keys(sources)) { + test(`${name}: page tool and schemas match the registry`, () => { + const h = harness(), d = h.definitions[name]; + const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); + const entry = registry.tools.find(e => e.domain === "doordash.com" && e.tool === name); + assert.equal(d.kind, "page"); + assert.equal(d.tool.name, name); + assert.deepEqual(plain(d.match.url_patterns), ["https://www.doordash.com/*"]); + assert.deepEqual(entry.url_patterns, plain(d.match.url_patterns)); + assert.equal(d.tool.annotations.readOnlyHint, name !== "remove_cart_item"); + assert.doesNotMatch(sources[name].trimEnd(), /;$/); + assert.doesNotMatch(sources[name], /\brequire\s*\(|\bimport\s|\bBuffer\b|\bprocess\./); + const copied = runInNewContext(`(${d.execute.toString()})`, { location: { origin: "https://example.invalid" } }); + assert.ok(copied); + }); + test(`${name}: malformed input before site interaction`, async () => { + const h = harness(); + for (const bad of [null, [], {}, { expected_account_email: "no-email" }, { ...read, confirmed: true }, { ...read, expected_account_email: " shopper@example.invalid" }, { ...read, expected_account_email: "a".repeat(260) + "@example.invalid" }]) { + assert.equal((await h.run(name, bad)).error_kind, "invalid_input"); + } + assert.equal(h.calls.length, 0); + }); + test(`${name}: wrong page and already cancelled`, async () => { + const input = name === "remove_cart_item" ? { ...read, cart_id: "cart-1", line_id: "line-1", expected_store_id: "1001", expected_revision: "a".repeat(64) } : name === "get_checkout_summary" ? { ...read, cart_id: "cart-1" } : read; + const h = harness({ origin: "https://help.doordash.com" }); + assert.equal((await h.run(name, input)).error_kind, "wrong_page"); + assert.equal(h.calls.length, 0); + const aborted = new AbortController(); aborted.abort(); + const h2 = harness(); + assert.equal((await h2.run(name, input, aborted.signal)).error_kind, "cancelled"); + assert.equal(h2.calls.length, 0); + }); +} + +// remove_cart_item accepts a get_cart revision, so the code that reads and hashes a cart must not drift between the tools. +test("cart query, projection and revision code is identical in every cart tool", () => { + const block = (source, name) => { + const lines = source.split("\n"), start = lines.findIndex(l => l.startsWith(` const ${name} = `)); + assert.notEqual(start, -1, name); + let end = start + 1; + while (end < lines.length && !/^ \S/.test(lines[end])) end++; + return lines.slice(start, /^ \}/.test(lines[end]) ? end + 1 : end).join("\n"); + }; + for (const name of ["cartQuery", "text", "ident", "integer", "bool", "canonical", "revision", "readCarts", "project"]) { + assert.equal(block(sources.get_checkout_summary, name), block(sources.get_cart, name), "get_checkout_summary " + name); + assert.equal(block(sources.remove_cart_item, name), block(sources.get_cart, name), "remove_cart_item " + name); + } +}); + +test("cart read exposes only allowlisted fields, minor amounts, options and revision", async () => { + const c = cart(); c.password = "private-password"; c.orders[0].paymentCard = { stripeId: "private-stripe" }; c.orders[0].orderItems[0].specialInstructions = "private-instruction"; + const h = harness({ carts: [c] }); const out = await h.run("get_cart", read); + assert.equal(out.status, "ok"); assert.equal(out.account_matched, true); + assert.equal(out.carts[0].subtotal_minor, 1400); assert.equal(out.carts[0].lines[0].options[0].name, "Tofu"); + assert.match(out.carts[0].revision, /^[a-f0-9]{64}$/); + assert.equal(out.delivery_context.basis, "selected_address"); + for (const secret of [email, "private-password", "private-stripe", "private-instruction", "secret-entry", "fabricated-token", "1 Fabricated Street"]) assert.ok(!JSON.stringify(out).includes(secret)); + assert.equal(h.calls.length, 3); assert.ok(h.calls.every(c => c.query.startsWith("query "))); + assert.equal(h.calls[0].headers["x-csrftoken"], "fabricated-token"); +}); + +test("empty cart list is an observed empty success; guest is not empty success", async () => { + const h = harness({ carts: [] }); assert.deepEqual((await h.run("get_cart", read)).carts, []); + for (const consumer of [null, { ...account(), isGuest: true }, { ...account(), email: null }]) { + const h2 = harness({ consumer }); assert.equal((await h2.run("get_cart", read)).error_kind, "login_required"); assert.equal(h2.calls.length, 1); + } +}); + +test("wrong account fails before private cart query", async () => { + const h = harness({ consumer: { ...account(), email: "other@example.invalid" } }); + assert.equal((await h.run("get_cart", read)).error_kind, "wrong_account"); assert.equal(h.calls.length, 1); +}); + +test("unknown account type fails closed, email match is case-insensitive", async () => { + assert.equal((await harness({ consumer: { ...account(), isGuest: null } }).run("get_cart", read)).error_kind, "site_changed"); + assert.equal((await harness().run("get_cart", { expected_account_email: email.toUpperCase() })).status, "ok"); +}); + +test("header basis and saved default region do not claim cart-selected address", async () => { + for (const [header, basis] of [["Your Address", "no_address_selected"], ["Tu dirección", "no_address_selected"], ["Votre adresse", "unknown"], [null, "unknown"], ["Plain Street", "unknown"]]) { + const out = await harness({ header }).run("get_cart", read); assert.equal(out.delivery_context.basis, basis); assert.match(out.delivery_context.note, /not a verified cart delivery address/); + } +}); + +test("preview never opens checkout and never fabricates approval", async () => { + const h = harness(); const out = await h.run("get_checkout_summary", { ...read, cart_id: "cart-1" }); + assert.equal(out.status, "ok"); assert.equal(out.ready_for_order, false); assert.match(out.execution_blocker, /trusted human approval/); + assert.equal(out.checkout_url, "https://www.doordash.com/consumer/checkout/?order_cart_id=cart-1"); + assert.ok(h.calls.every(c => !c.url.includes("checkout") && !c.query.includes("mutation"))); + assert.equal((await h.run("get_checkout_summary", { ...read, cart_id: "cart-1", confirmed: true })).error_kind, "invalid_input"); + assert.equal((await h.run("get_checkout_summary", { ...read, cart_id: "missing" })).error_kind, "not_found"); +}); + +test("cart projection bounds and truncation flags", async () => { + const c = cart(); c.orders[0].orderItems = Array.from({ length: 101 }, (_, i) => item("line-" + i)); + c.orders[0].orderItems[0].options = Array.from({ length: 51 }, (_, i) => ({ id: "o-" + i, name: "x", quantity: 1, price: 2 })); + const h = harness({ carts: [c] }); const out = await h.run("get_cart", read); + assert.equal(out.carts[0].lines.length, 100); assert.equal(out.carts[0].lines[0].options.length, 50); assert.equal(out.carts[0].truncated, true); +}); + +for (const [status, raw, kind] of [[401, "", "login_required"], [429, "", "rate_limited"], [403, "", "blocked"], [200, "captcha", "blocked"], [500, "{}", "site_changed"]]) { + test(`read failure ${status}/${kind} is explicit and no write`, async () => { + const h = harness({ respond: () => ({ status, raw }) }); + const out = await h.run("get_cart", read); assert.equal(out.error_kind, kind); assert.equal(out.write_attempted, false); assert.equal(writes(h).length, 0); + }); +} + +test("GraphQL private error text is never echoed", async () => { + const h = harness({ respond: () => ({ json: { errors: [{ message: "private-token-in-error", extensions: { code: "FORBIDDEN" } }] } }) }); + const out = await h.run("get_cart", read); assert.equal(out.error_kind, "blocked"); assert.ok(!JSON.stringify(out).includes("private-token")); +}); + +test("mocked exact removal, one write, observed unchanged other line, guard cleared", async () => { + const h = harness(); const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.status, "ok"); assert.equal(out.removed, true); assert.deepEqual(out.remaining_cart.lines.map(l => l.line_id), ["line-2"]); + assert.equal(writes(h).length, 1); assert.equal(h.store.size, 0); + assert.equal(writes(h)[0].variables.itemId, "line-1"); + assert.deepEqual(writes(h)[0].variables.cartContext, { deleteBundleCarts: false }); + assert.equal(writes(h)[0].variables.returnCartFromOrderService, false); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "blocked"); assert.equal(writes(h).length, 1); +}); + +test("stale revision and wrong store prevent any write", async () => { + const h = harness(); const input = await removalInput(h); + for (const bad of [{ ...input, expected_revision: "a".repeat(64) }, { ...input, expected_store_id: "1002" }]) assert.equal((await h.run("remove_cart_item", bad)).error_kind, "stale_state"); + assert.equal(writes(h).length, 0); +}); + +for (const flag of ["groupCart", "isBundle", "isCatering", "isMerchantShipping", "isPrescriptionDelivery"]) { + test(`removal refuses ${flag} and unknown ${flag}`, async () => { + for (const value of [true, null, undefined]) { + const c = cart(); c[flag] = value; const h = harness({ carts: [c] }); const input = await removalInput(h); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "blocked"); assert.equal(writes(h).length, 0); + } + }); +} + +test("finalized, multi-order, last-line and duplicate-line carts are refused", async () => { + for (const change of [c => { c.orders[0].isSubCartFinalized = true; }, c => { delete c.orders[0].isSubCartFinalized; }, c => { c.orders.push(plain(c.orders[0])); }, c => { c.orders[0].orderItems.pop(); }, c => { c.orders[0].orderItems[1].id = "line-1"; }]) { + const c = cart(); change(c); const h = harness({ carts: [c] }); const input = await removalInput(h); + const out = await h.run("remove_cart_item", input); assert.notEqual(out.status, "ok"); assert.equal(writes(h).length, 0); + } +}); + +test("missing cart, missing line and storage denial prevent a write", async () => { + const h = harness(); const input = await removalInput(h); + for (const key of ["cart_id", "line_id"]) assert.equal((await h.run("remove_cart_item", { ...input, [key]: "missing" })).error_kind, "not_found"); + assert.equal(writes(h).length, 0); + const h2 = harness({ storageError: true }); const i2 = await removalInput(h2); assert.equal((await h2.run("remove_cart_item", i2)).error_kind, "blocked"); assert.equal(writes(h2).length, 0); +}); + +test("post-write wrong account does not claim success and retains the guard", async () => { + let h; h = harness({ afterWrite: () => h.setConsumer({ ...account(), email: "other@example.invalid" }) }); + const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); assert.equal(h.store.size, 1); +}); + +for (const options of [{ writeError: true }, { noRemoval: true }, { nullAck: true }, { afterWrite: c => { c.orders[0].orderItems[0].quantity = 9; } }, { afterWrite: c => { c.restaurant.id = "1002"; } }]) { + test(`uncertain mocked write fails closed, is never retried (${Object.keys(options)[0]})`, async () => { + const h = harness(options); const input = await removalInput(h); const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); assert.equal(h.store.size, 1); assert.equal(writes(h).length, 1); + await h.run("remove_cart_item", input); assert.equal(writes(h).length, 1); + }); +} + +test("remaining lines repriced by the site still confirm the removal", async () => { + const h = harness({ afterWrite: c => { Object.assign(c.orders[0].orderItems[0], { singlePrice: 600, priceOfTotalQuantity: 600, priceDisplayString: "$6.00" }); } }); + const out = await h.run("remove_cart_item", await removalInput(h)); + assert.equal(out.status, "ok"); assert.equal(out.remaining_cart.lines[0].total_minor, 600); assert.equal(h.store.size, 0); +}); + +test("remaining lines listed in another order still confirm the removal", async () => { + const three = cart(); three.orders[0].orderItems.push(item("line-3")); + const h = harness({ carts: [three], afterWrite: c => { c.orders[0].orderItems.reverse(); } }); + const out = await h.run("remove_cart_item", await removalInput(h)); + assert.equal(out.status, "ok"); assert.deepEqual(out.remaining_cart.lines.map(l => l.line_id), ["line-3", "line-2"]); assert.equal(h.store.size, 0); +}); + +test("an uncertain write blocks the unchanged cart but not a fresh revision of a changed one", async () => { + const h = harness({ noRemoval: true }); const input = await removalInput(h); + assert.equal((await h.run("remove_cart_item", input)).error_kind, "outcome_unknown"); assert.equal(h.store.size, 1); + assert.equal((await h.run("remove_cart_item", await removalInput(h))).error_kind, "busy"); assert.equal(writes(h).length, 1); + h.getCarts()[0].orders[0].orderItems.push(item("line-3")); + assert.equal((await h.run("remove_cart_item", await removalInput(h))).error_kind, "outcome_unknown"); assert.equal(writes(h).length, 2); +}); + +test("concurrent removals share a per-cart guard, at most one write", async () => { + const h = harness({ writeError: true }); const input = await removalInput(h); + const out = await Promise.all([h.run("remove_cart_item", input), h.run("remove_cart_item", input)]); + assert.ok(out.some(o => o.error_kind === "busy")); assert.equal(writes(h).length, 1); +}); + + +for (const name of ["get_cart", "get_checkout_summary"]) { + test(`${name}: observable account switch after private read discards all cart data`, async () => { + let h; + h = harness({ respond: body => { + if (body.operationName === "listDetailedCarts") { + h.setConsumer({ ...account(), email: "other@example.invalid" }); + const other = cart(); other.restaurant.name = "OTHER_ACCOUNT_CART"; + return { json: { data: { listDetailedCarts: { detailedCarts: [{ cart: other }] } } } }; + } + } }); + const out = await h.run(name, name === "get_cart" ? read : { ...read, cart_id: "cart-1" }); + assert.equal(out.error_kind, "wrong_account"); + assert.ok(!JSON.stringify(out).includes("OTHER_ACCOUNT_CART")); + assert.equal(writes(h).length, 0); + }); +} + +for (const [label, change] of [ + ["prescription", l => { l.isPrescriptionItem = true; }], + ["unknown prescription status", l => { delete l.isPrescriptionItem; }], + ["missing item", l => { l.item = null; }], + ["missing item id", l => { l.item.id = null; }], + ["conflicting store", l => { l.item.storeId = "1002"; }], + ["missing options", l => { l.options = null; }], + ["missing option id", l => { l.options[0].id = null; }], + ["invalid quantity", l => { l.quantity = 0; }] +]) { + test(`removal refuses ${label} before any guard or write`, async () => { + for (const lineIndex of [0, 1]) { + const c = cart(); change(c.orders[0].orderItems[lineIndex]); + const h = harness({ carts: [c] }); const input = await removalInput(h); + const out = await h.run("remove_cart_item", input); + assert.equal(out.error_kind, "blocked"); assert.equal(out.write_attempted, false); + assert.equal(writes(h).length, 0); assert.equal(h.store.size, 0); + } + }); +} + +test("late cancellation during final digest retains the uncertain-outcome guard", async () => { + const controller = new AbortController(); + let actionStarted = false, digests = 0; + const crypto = { randomUUID: () => webcrypto.randomUUID(), subtle: { digest: async (...args) => { + if (actionStarted && ++digests === 3) controller.abort(); + return webcrypto.subtle.digest(...args); + } } }; + const h = harness({ crypto }); const input = await removalInput(h); + actionStarted = true; + const out = await h.run("remove_cart_item", input, controller.signal); + assert.equal(out.error_kind, "outcome_unknown"); assert.equal(out.write_attempted, true); + assert.equal(writes(h).length, 1); assert.equal(h.store.size, 1); + await h.run("remove_cart_item", input); assert.equal(writes(h).length, 1); +}); diff --git a/tests/doordash-get-store.test.mjs b/tests/doordash-get-store.test.mjs new file mode 100644 index 00000000..d20dabd0 --- /dev/null +++ b/tests/doordash-get-store.test.mjs @@ -0,0 +1,432 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +// Fixture tests for doordash.com/get_store. The page (location, document, fetch) is stubbed; +// nothing here contacts the live site and every store, id, address and cookie is fabricated. +const source = readFileSync(new URL("../domains/doordash.com/get_store.js", import.meta.url), "utf8"); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const plain = value => JSON.parse(JSON.stringify(value)); +const HOME = "https://www.doordash.com"; +const HEADER_STREET = "1 Fabricated Header Street, Apt 9"; + +function conforms(schema, value) { + if (schema.anyOf && !schema.anyOf.some(s => conforms(s, value))) return false; + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if ("const" in schema && value !== schema.const) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.type && ![schema.type].flat().some(type => { + if (type === "null") return value === null; + if (type === "integer") return Number.isInteger(value); + if (type === "array") return Array.isArray(value); + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === type; + })) return false; + if (Array.isArray(value)) { + if (schema.maxItems !== undefined && value.length > schema.maxItems) return false; + if (schema.items && !value.every(item => conforms(schema.items, item))) return false; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(key => !Object.hasOwn(value, key))) return false; + const props = schema.properties || {}; + if (Object.entries(props).some(([key, s]) => key in value && !conforms(s, value[key]))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(key => !(key in props))) return false; + } + if (typeof value === "string") { + if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) return false; + if (schema.pattern && !new RegExp(schema.pattern).test(value)) return false; + } + if (typeof value === "number" && (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity))) return false; + return true; +} + +// --- fabricated site data ------------------------------------------------------------------------ + +const week = (slot, only) => ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"].slice(0, only ?? 7) + .map(dayOfWeek => ({ dayOfWeek, timeSlotList: [slot] })); + +const feed = (overrides = {}) => ({ + storeHeader: { + id: "1001", name: "Fabricated Noodle House", description: "Noodles", timezone: "US/Eastern", priceRange: 2, priceRangeDisplayString: "$$", + offersDelivery: true, offersPickup: true, offersGroupOrder: true, offersCatering: false, offersScheduling: true, isConvenience: false, isDashpassPartner: true, + address: { lat: "40.5", lng: "-73.5", city: "Testville", state: "NY", street: "5 Test Road" }, + businessTags: [{ name: "Noodles" }, { name: "Asian" }], + ratings: { numRatings: 1234, numRatingsDisplayString: "(1k+)", averageRating: 4.6, isNewlyAdded: false }, + deliveryFeeLayout: { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", isSurging: false, displayDeliveryFee: "$0 delivery fee over $12" }, + asapStatus: { displayUnavailableReason: { title: "" } }, + status: { + delivery: { isAvailable: true, minutes: "27 - 37", unavailableReason: "", isTooFarFromConsumer: false }, + pickup: { isAvailable: true, minutes: "19 - 29", unavailableReason: "" } + }, + ...overrides.storeHeader + }, + mxInfo: { + operationInfo: { + operationStatusInfo: { description: "Accepting DoorDash orders until 9 PM", operationStatus: "OPEN" }, + storeOperationHourInfo: { operationSchedule: week("10 AM - 10 PM") }, + doordashOperationHourInfo: { operationSchedule: week("10 AM - 9 PM") } + } + }, + menuBook: { id: "5001", name: "All Day", displayOpenHours: "10:00 AM - 9:00 PM", menuList: [{ id: "5001", name: "All Day", displayOpenHours: "10:00 AM - 9:00 PM" }, { id: "5002", name: "Lunch", displayOpenHours: "11:00 AM - 3:00 PM" }] }, + ...overrides.rest +}); +const ok = data => ({ status: 200, json: { data: { storepageFeed: data } } }); + +function harness(options = {}) { + const calls = []; + const responder = options.respond ?? (() => ok(feed())); + const stubFetch = async (url, init) => { + calls.push({ url, init: { ...init, body: init.body ? JSON.parse(init.body) : undefined } }); + if (options.fetchError) throw options.fetchError; + const reply = responder(url, init); + const body = reply.body ?? JSON.stringify(reply.json); + return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => body }; + }; + const button = options.button === undefined ? { innerText: HEADER_STREET } : options.button; + const signalSpy = { timeouts: [], anys: 0 }; + const SpiedAbortSignal = { timeout: ms => { signalSpy.timeouts.push(ms); return AbortSignal.timeout(ms); }, any: list => { signalSpy.anys += 1; return AbortSignal.any(list); } }; + const context = { + location: { origin: options.origin ?? HOME, pathname: "/home" }, + document: { + cookie: options.cookie ?? "a=1; csrf_token=fabricated-csrf; dd_cx_logged_in=true", + querySelector: selector => selector === "[data-testid='addressTextButton']" ? button : selector === "[data-testid='LandingPageAddressSearch']" && options.landing ? {} : null + }, + fetch: stubFetch, AbortSignal: SpiedAbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite + }; + const [definition] = runInNewContext(`(${source})`, context); + return { definition, calls, signalSpy, run: (input, signal) => definition.execute(input, signal ? { signal } : undefined) }; +} +const validOutput = (definition, value) => conforms(definition.tool.outputSchema, value); + +// --- contract ------------------------------------------------------------------------------------ + +test("single page definition, narrowed to www, agrees with the registry", () => { + const { definition } = harness(); + assert.equal(definition.kind, "page"); + assert.equal(definition.tool.name, "get_store"); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.doordash.com/*"]); + const entry = registry.tools.find(t => t.domain === "doordash.com" && t.tool === "get_store"); + assert.ok(entry, "registry entry exists"); + assert.equal(entry.kind, "page"); + assert.deepEqual(entry.url_patterns, plain(definition.match.url_patterns)); + assert.equal(entry.file, "domains/doordash.com/get_store.js"); + assert.equal(definition.tool.annotations.readOnlyHint, true); + assert.ok(definition.tool.outputSchema && definition.tool.inputSchema); +}); + +test("source has no import, require, Node globals or trailing semicolon, and execute is self-contained", () => { + assert.doesNotMatch(source, /\brequire\s*\(|\bimport\s|\bBuffer\b|\bprocess\./); + assert.doesNotMatch(source.trimEnd(), /;$/); + // The runtime re-creates a page tool's execute in the page from its source text alone. + const { definition } = harness(); + const stubs = { location: { origin: HOME }, document: { cookie: "", querySelector: () => null }, fetch: async () => ({ status: 500, ok: false, text: async () => "" }), AbortSignal }; + const recreated = runInNewContext(`(${definition.execute.toString()})`, stubs); + assert.equal(typeof recreated, "function"); +}); + +// --- success paths ------------------------------------------------------------------------------- + +test("restaurant: maps the allowlisted fields and issues exactly one read", async () => { + const { definition, calls, run } = harness(); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.status, "ok"); + assert.equal(out.store_id, "1001"); + assert.equal(out.url, "https://www.doordash.com/store/1001/"); + assert.equal(out.store_type, "restaurant"); + assert.deepEqual(out.cuisines, ["Noodles", "Asian"]); + assert.deepEqual(out.price_range, { level: 2, text: "$$" }); + assert.deepEqual(out.rating, { average: 4.6, count: 1234, count_text: "(1k+)", is_new: false }); + assert.deepEqual(out.address, { street: "5 Test Road", city: "Testville", state: "NY", latitude: 40.5, longitude: -73.5 }); + assert.equal(out.offers.dashpass_partner, true); + assert.equal(out.operation_status, "OPEN"); + assert.equal(out.hours.store.length, 7); + assert.deepEqual(out.hours.doordash_orders[0], { day: "MONDAY", slots: ["10 AM - 9 PM"] }); + assert.equal(out.delivery_context.basis, "selected_address"); + assert.equal(out.delivery_context.signed_in, true); + assert.equal(out.delivery.available, true); + assert.equal(out.delivery.estimate_minutes, "27 - 37"); + assert.equal("distance" in out.delivery_context, false); + assert.deepEqual(out.delivery.fee_display, { title: "$0 delivery fee on $12+", subtitle: "pricing & fees", text: "$0 delivery fee over $12", is_surging: false }); + assert.equal(out.pickup.estimate_minutes, "19 - 29"); + assert.deepEqual(out.menu.menus.map(m => m.menu_id), ["5001", "5002"]); + assert.equal(out.menu.menu_id, "5001"); + assert.equal(out.menu.menus_truncated, false); + + assert.equal(calls.length, 1); + const [call] = calls; + assert.equal(call.url, "/graphql/storepageFeed?operation=storepageFeed"); + assert.equal(call.init.method, "POST"); + assert.equal(call.init.credentials, "include"); + assert.equal(call.init.headers["x-csrftoken"], "fabricated-csrf"); + assert.equal(call.init.headers["x-channel-id"], "marketplace"); + assert.deepEqual(call.init.body.variables, { storeId: "1001", fulfillmentType: "Delivery", isMerchantPreview: false, entryPoint: "External" }); + assert.match(call.init.body.query, /^query storepageFeed\(/); + assert.doesNotMatch(call.init.body.query, /mutation/i); + assert.doesNotMatch(call.init.body.query, /distanceFromConsumer/, "the customer's distance is never requested"); +}); + +test("signed out: empty csrf header, signed_in false", async () => { + const { calls, run } = harness({ cookie: "a=1" }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(calls[0].init.headers["x-csrftoken"], ""); + assert.equal(out.delivery_context.signed_in, false); +}); + +const farStore = () => ok(feed({ storeHeader: { + asapStatus: { displayUnavailableReason: { title: "Your address is not in this store's delivery area" } }, + status: { + delivery: { isAvailable: false, minutes: "51 - 61", unavailableReason: "Too far away", isTooFarFromConsumer: true }, + pickup: { isAvailable: false, minutes: "6 - 16", unavailableReason: "" } + } +} })); + +test("selected address, store out of range: the reason is reported and no estimate is invented", async () => { + const { definition, run } = harness({ respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "selected_address"); + assert.equal(out.delivery.available, false); + assert.equal(out.delivery.estimate_minutes, null); + assert.equal(out.delivery.unavailable_reason, "Too far away"); + assert.equal(out.delivery.unavailable_message, "Your address is not in this store's delivery area"); + assert.equal(out.pickup.available, false); + assert.equal(out.pickup.estimate_minutes, null); + assert.equal(out.delivery_context.too_far_from_consumer, true); + assert.match(out.delivery_context.note, /never changes the address/); +}); + +test("no address selected: the placeholder-location delivery figures are withheld, pickup and store data are not", async () => { + const { definition, run } = harness({ button: { innerText: "Your Address" }, respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "no_address_selected"); + assert.equal(out.delivery_context.address_selected, false); + assert.deepEqual(out.delivery, { available: null, unavailable_reason: null, unavailable_message: null, estimate_minutes: null, fee_display: { title: null, subtitle: null, text: null, is_surging: null } }); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.equal(out.name, "Fabricated Noodle House"); + assert.equal(out.operation_status, "OPEN"); + assert.equal(out.hours.store.length, 7); + assert.doesNotMatch(JSON.stringify(out), /Too far away|delivery fee/, "the fee strings change with the address, so they are withheld too"); + assert.match(out.delivery_context.note, /withheld/); + + const available = harness({ button: { innerText: "Your Address" } }); + const open = plain(await available.run({ store_id: "1001" })); + assert.equal(open.delivery.available, null, "an 'available' answer for a placeholder location is withheld too"); + assert.equal(open.delivery.estimate_minutes, null); + assert.equal(open.pickup.estimate_minutes, "19 - 29"); +}); + +test("unknown basis: delivery figures are withheld like the no-address case, not passed through", async () => { + const { definition, run } = harness({ button: null, respond: farStore }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.delivery_context.basis, "unknown"); + assert.equal(out.delivery_context.address_selected, null); + assert.equal(out.delivery.available, null); + assert.equal(out.delivery.unavailable_reason, null); + assert.equal(out.delivery.fee_display.text, null); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.equal(out.pickup.available, false); + assert.doesNotMatch(JSON.stringify(out), /Too far away/); +}); + +test("basis is recognised positively, in any locale, and the header text is never returned", async () => { + const basisOf = async options => plain(await harness(options).run({ store_id: "1001" })).delivery_context; + const selected = await basisOf({}); + assert.equal(selected.basis, "selected_address"); + assert.equal(selected.address_selected, true); + assert.ok(!JSON.stringify(plain(await harness().run({ store_id: "1001" }))).includes("Fabricated Header Street")); + + assert.equal((await basisOf({ button: { innerText: "Your Address" } })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: { innerText: " your address " } })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: { innerText: "Tu dirección" } })).basis, "no_address_selected"); + const unrecognised = await basisOf({ button: { innerText: "Votre adresse" } }); + assert.equal(unrecognised.basis, "unknown", "an unrecognised placeholder must not read as a selected address"); + assert.equal(unrecognised.address_selected, null); + assert.equal((await basisOf({ button: { innerText: "Main Street" } })).basis, "unknown"); + for (const mixed of ["set your address", "your address please", "Your Address, Suite", "Tu dirección aquí"]) { + assert.equal((await basisOf({ button: { innerText: mixed } })).basis, "unknown", "a placeholder is matched as the whole text: " + mixed); + } + assert.equal((await basisOf({ button: { innerText: "Your Address 2" } })).basis, "selected_address", "any digit counts as an address"); + assert.equal((await basisOf({ button: { innerText: "12 Rue Fabriquée" } })).basis, "selected_address"); + assert.equal((await basisOf({ button: { innerText: " " } })).basis, "unknown"); + assert.equal((await basisOf({ button: null })).basis, "unknown"); + assert.equal((await basisOf({ button: null, landing: true })).basis, "no_address_selected"); + assert.equal((await basisOf({ button: null, landing: true })).address_selected, false); +}); + +test("convenience store: header details, canonical convenience URL, no menu", async () => { + const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { id: "2002", name: "Fabricated Market", isConvenience: true } })) }); + const out = plain(await run({ store_id: "2002" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.store_type, "convenience"); + assert.equal(out.url, "https://www.doordash.com/convenience/store/2002/"); + assert.equal(out.menu, null, "a convenience store never reports a menu, even if the site sends one"); +}); + +test("a store with no ratings reports no average instead of 0", async () => { + const { definition, run } = harness({ respond: () => ok(feed({ storeHeader: { ratings: { numRatings: 0, numRatingsDisplayString: "", averageRating: 0, isNewlyAdded: true } } })) }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out)); + assert.deepEqual(out.rating, { average: null, count: 0, count_text: null, is_new: true }); +}); + +test("optional and missing fields become nulls, not errors", async () => { + const { definition, run } = harness({ + respond: () => ok({ + storeHeader: { id: "1001", name: "Sparse Store", status: {} }, + mxInfo: null, menuBook: { id: null, menuList: [] } + }) + }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out)); + assert.equal(out.rating.average, null); + assert.equal(out.operation_status, null); + assert.deepEqual(out.hours, { store: [], doordash_orders: [] }); + assert.equal(out.delivery.available, null); + assert.equal(out.delivery_context.too_far_from_consumer, null); + assert.deepEqual(out.menu.menus, []); + assert.equal(out.menu.menu_id, null); +}); + +test("output is bounded: menus, cuisines, hours slots and long strings are capped", async () => { + const many = Array.from({ length: 30 }, (_, i) => ({ id: String(9000 + i), name: "Menu " + i, displayOpenHours: "All day" })); + const { definition, run } = harness({ + respond: () => ok(feed({ + storeHeader: { description: "x".repeat(5000), businessTags: Array.from({ length: 25 }, (_, i) => ({ name: "Tag " + i })) }, + rest: { + menuBook: { id: "9000", name: "Big", displayOpenHours: "All day", menuList: many }, + mxInfo: { operationInfo: { storeOperationHourInfo: { operationSchedule: [...week("a"), ...week("b")] }, doordashOperationHourInfo: { operationSchedule: [{ dayOfWeek: "MONDAY", timeSlotList: Array.from({ length: 12 }, (_, i) => "slot " + i) }] } } } + } + })) + }); + const out = plain(await run({ store_id: "1001" })); + assert.ok(validOutput(definition, out), JSON.stringify(out).slice(0, 300)); + assert.equal(out.menu.menus.length, 20); + assert.equal(out.menu.menus_truncated, true); + assert.equal(out.cuisines.length, 10); + assert.equal(out.description.length, 300); + assert.equal(out.hours.store.length, 7); + assert.equal(out.hours.doordash_orders[0].slots.length, 6); + assert.ok(JSON.stringify(out).length < 20000); +}); + +// --- failures ------------------------------------------------------------------------------------ + +test("malformed input is rejected before any request", async () => { + const { definition, calls, run } = harness(); + for (const input of [undefined, null, [], "1001", {}, { store_id: 1001 }, { store_id: "" }, { store_id: "abc" }, { store_id: "12 34" }, { store_id: "-1" }, { store_id: "1234567890123" }, { store_id: "029417" }, { store_id: "0" }, { store_id: " 1001" }, { store_id: "1001 " }, { store_id: "1001\n" }, { store_id: "١٢٣" }, { store_id: "1001", extra: true }]) { + const out = plain(await run(input)); + assert.equal(out.status, "error", JSON.stringify(input)); + assert.equal(out.error_kind, "invalid_input"); + assert.ok(validOutput(definition, out)); + } + assert.equal(calls.length, 0); +}); + +test("execute accepts exactly the ids the input schema pattern accepts", async () => { + const { definition, calls, run } = harness(); + const pattern = new RegExp(definition.tool.inputSchema.properties.store_id.pattern); + for (const id of ["1", "1001", "123456789012", "0", "01", "029417", "1234567890123", "", " 1001", "1001 ", "1001\n", "10.1", "1e3", "\u0661\u0662"]) { + const before = calls.length; + const out = plain(await run({ store_id: id })); + const accepted = out.error_kind !== "invalid_input"; + assert.equal(accepted, pattern.test(id), JSON.stringify(id)); + assert.equal(calls.length - before, accepted ? 1 : 0, JSON.stringify(id)); + } +}); + +test("signed_in reads exactly dd_cx_logged_in=true", async () => { + const signedIn = async cookie => plain(await harness({ cookie }).run({ store_id: "1001" })).delivery_context.signed_in; + assert.equal(await signedIn("dd_cx_logged_in=true"), true); + assert.equal(await signedIn("a=1; dd_cx_logged_in=true; b=2"), true); + assert.equal(await signedIn("a=1; dd_cx_logged_in=true"), true); + assert.equal(await signedIn("dd_cx_logged_in=false"), false); + assert.equal(await signedIn("dd_cx_logged_in=truex"), false); + assert.equal(await signedIn("xdd_cx_logged_in=true"), false); + assert.equal(await signedIn(""), false); +}); + +test("a page outside www.doordash.com is refused without a request", async () => { + const { definition, calls, run } = harness({ origin: "https://help.doordash.com" }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(out.error_kind, "wrong_page"); + assert.ok(validOutput(definition, out)); + assert.equal(calls.length, 0); +}); + +test("unknown store ids are not_found, whether DoorDash errors or returns an empty header", async () => { + const errored = harness({ respond: () => ({ status: 200, json: { data: { storepageFeed: null }, errors: [{ message: "5 NOT_FOUND: no stores returned", extensions: { code: "INTERNAL_SERVER_ERROR" } }] } }) }); + const a = plain(await errored.run({ store_id: "99999999" })); + assert.equal(a.error_kind, "not_found"); + assert.ok(validOutput(errored.definition, a)); + const empty = harness({ respond: () => ok(feed({ storeHeader: { id: "1", name: "" } })) }); + assert.equal(plain(await empty.run({ store_id: "1" })).error_kind, "not_found"); +}); + +test("permission, rate-limit, challenge, site-change and generic failures are distinct errors", async () => { + const cases = [ + ["HTTP 401", { status: 401, body: "{}" }, "login_required"], + ["unauthenticated GraphQL error", { status: 200, json: { data: null, errors: [{ message: "unauthenticated request", extensions: { code: "UNAUTHENTICATED" } }] } }, "login_required"], + ["HTTP 429", { status: 429, body: "slow down" }, "rate_limited"], + ["challenge page", { status: 403, body: "Just a moment...
" }, "challenge"], + ["challenge page with HTTP 200", { status: 200, body: "Please verify you are human. captcha" }, "challenge"], + ["non-JSON without a challenge", { status: 502, body: "Bad gateway" }, "site_error"], + ["schema change", { status: 200, json: { errors: [{ message: "Cannot query field \"storeHeader\" on type \"Feed\"", extensions: { code: "GRAPHQL_VALIDATION_FAILED" } }] } }, "site_changed"], + ["missing store header", { status: 200, json: { data: { storepageFeed: { mxInfo: {} } } } }, "site_changed"], + ["null feed without errors", { status: 200, json: { data: { storepageFeed: null } } }, "site_changed"], + ["another store's data", ok(feed({ storeHeader: { id: "7777" } })), "site_changed"], + ["server error JSON", { status: 500, json: {} }, "site_error"], + ["other GraphQL error", { status: 200, json: { errors: [{ message: "internal failure" }] } }, "site_error"] + ]; + for (const [label, reply, kind] of cases) { + const { definition, calls, run } = harness({ respond: () => reply }); + const out = plain(await run({ store_id: "1001" })); + assert.equal(out.status, "error", label); + assert.equal(out.error_kind, kind, label + ": " + JSON.stringify(out)); + assert.ok(out.error_message.length > 0); + assert.ok(validOutput(definition, out), label); + assert.equal(calls.length, 1, label + " must not retry"); + } +}); + +test("the request is bounded by a 25 second timeout and error text from the site is bounded", async () => { + const { signalSpy, run } = harness(); + await run({ store_id: "1001" }); + assert.deepEqual(signalSpy.timeouts, [25000]); + assert.equal(signalSpy.anys, 1); + const noisy = harness({ respond: () => ({ status: 200, json: { errors: [{ message: "internal failure " + "x".repeat(50000) }] } }) }); + const out = plain(await noisy.run({ store_id: "1001" })); + assert.equal(out.error_kind, "site_error"); + assert.ok(out.error_message.length < 300, String(out.error_message.length)); + const invalid = harness({ respond: () => ({ status: 200, json: { errors: [{ message: "Cannot query field " + "y".repeat(50000), extensions: { code: "GRAPHQL_VALIDATION_FAILED" } }] } }) }); + assert.ok(plain(await invalid.run({ store_id: "1001" })).error_message.length < 400); +}); + +test("network failure and timeout are site_error and are not retried", async () => { + const failed = harness({ fetchError: new TypeError("Failed to fetch") }); + const a = plain(await failed.run({ store_id: "1001" })); + assert.equal(a.error_kind, "site_error"); + assert.equal(failed.calls.length, 1); + const timedOut = harness({ fetchError: Object.assign(new Error("timed out"), { name: "TimeoutError" }) }); + assert.match(plain(await timedOut.run({ store_id: "1001" })).error_message, /25 seconds/); +}); + +test("the caller's abort signal is passed to the request", async () => { + const controller = new AbortController(); + const { calls, run } = harness(); + await run({ store_id: "1001" }, controller.signal); + assert.ok(calls[0].init.signal instanceof AbortSignal); + controller.abort(); + assert.equal(calls[0].init.signal.aborted, true); +}); + +test("the tool never navigates, writes, or reads anything but the header control and two cookies", async () => { + const { calls, run } = harness(); + await run({ store_id: "1001" }); + assert.equal(calls.length, 1); + assert.match(calls[0].init.body.query, /^query /); + assert.doesNotMatch(source, /location\.(assign|replace|href\s*=)|history\.|localStorage|sessionStorage|\.click\(|dispatchEvent|\.submit\(/); +}); diff --git a/tests/doordash-set-delivery-address.test.mjs b/tests/doordash-set-delivery-address.test.mjs new file mode 100644 index 00000000..4cf9c29f --- /dev/null +++ b/tests/doordash-set-delivery-address.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; + +// Fixture tests for doordash.com/set_delivery_address. The page (location, document, fetch) is stubbed; +// nothing here contacts the live site and every address, id and cookie is fabricated. +const source = readFileSync(new URL("../domains/doordash.com/set_delivery_address.js", import.meta.url), "utf8"); +const registry = JSON.parse(readFileSync(new URL("../registry.json", import.meta.url), "utf8")); +const plain = value => JSON.parse(JSON.stringify(value)); +const HOME = "https://www.doordash.com"; +const AUTOCOMPLETE = "/unified-gateway/geo-intelligence/v2/address/autocomplete?input_address="; +const CREATE = "/unified-gateway/geo-intelligence/v2/address/get-or-create"; +const MUTATION = "/graphql/addConsumerAddressV2?operation=addConsumerAddressV2"; +const BUCKETING = "/graphql/consumerBucketing?operation=consumerBucketing"; + +function conforms(schema, value) { + if (schema.oneOf && schema.oneOf.filter(s => conforms(s, value)).length !== 1) return false; + if ("const" in schema && value !== schema.const) return false; + if (schema.enum && !schema.enum.includes(value)) return false; + if (schema.type && ![schema.type].flat().some(type => { + if (type === "null") return value === null; + if (type === "integer") return Number.isInteger(value); + if (type === "array") return Array.isArray(value); + if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value); + return typeof value === type; + })) return false; + if (Array.isArray(value)) { + if (schema.maxItems !== undefined && value.length > schema.maxItems) return false; + if (schema.items && !value.every(item => conforms(schema.items, item))) return false; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (schema.required?.some(key => !Object.hasOwn(value, key))) return false; + const props = schema.properties || {}; + if (Object.entries(props).some(([key, s]) => key in value && !conforms(s, value[key]))) return false; + if (schema.additionalProperties === false && Object.keys(value).some(key => !(key in props))) return false; + } + return true; +} + +// --- fabricated site data ------------------------------------------------------------------------ + +const prediction = (n, extra = {}) => ({ + lat: 40.1 + n / 1000, lng: -73.1, formatted_address: `${n} Fabricated Plaza, Testville, NY 10001, USA`, formatted_address_short: `${n} Fabricated Plaza`, + source_place_id: `fabricated-source-place-${n}`, postal_code: "10001", administrative_area_level1: "NY", locality: "Testville", street_address: `${n} Fabricated Plaza`, ...extra +}); +const place = (n, extra = {}) => ({ + id: String(900 + n), lat: 40.1 + n / 1000, lng: -73.1, formatted_address: `${n} Fabricated Plaza, Testville, NY 10001, USA`, street_address: `${n} Fabricated Plaza`, + locality: "Testville", administrative_area_level1: "NY", postal_code: "10001", formatted_address_short: `${n} Fabricated Plaza`, + formatted_address_segmented: [`${n} Fabricated Plaza`, "Testville, NY 10001, USA"], source_place_id: `P000FAKE${n}`, ...extra +}); +const saved = (n, extra = {}) => ({ + data: { addConsumerAddressV2: { defaultAddress: { id: String(900 + n), street: `${n} Fabricated Plaza`, city: "Testville", state: "NY", zipCode: "10001", printableAddress: `${n} Fabricated Plaza, Testville, NY 10001, USA`, submarket: "Testville" }, isGuest: true, ...extra } } +}); + +function harness(options = {}) { + const calls = []; + const replies = { + [BUCKETING]: () => ({ status: 200, json: { data: { consumer: { isGuest: true } } } }), + [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [prediction(1), prediction(2), prediction(3)] } }), + [CREATE]: init => ({ status: 200, json: { address: place(Number(/\d+$/.exec(JSON.parse(init.body).address_identifier.source_place_id)[0])) } }), + [MUTATION]: init => ({ status: 200, json: saved(Number(JSON.parse(init.body).variables.addressId) - 900) }), + ...options.replies + }; + const stubFetch = async (url, init) => { + calls.push({ url, init: { ...init, body: init.body ? JSON.parse(init.body) : undefined } }); + if (options.fetchError) throw options.fetchError; + const key = Object.keys(replies).find(k => url.startsWith(k)); + assert.ok(key, "unexpected request " + url); + const reply = replies[key](init); + const body = reply.body ?? JSON.stringify(reply.json); + return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => body }; + }; + const context = { + location: { origin: options.origin ?? HOME, pathname: "/" }, + document: { cookie: options.cookie ?? "a=1; csrf_token=fabricated-csrf" }, + fetch: stubFetch, AbortSignal, JSON, Object, Array, Number, String, Boolean, Math, Promise, Error, RegExp, isFinite, encodeURIComponent + }; + const [definition] = runInNewContext(`(${source})`, context); + return { + definition, calls, + run: async input => { + const result = plain(await definition.execute(input)); + assert.ok(conforms(definition.tool.outputSchema, result), JSON.stringify(result).slice(0, 300)); + return result; + } + }; +} + +// --- contract ------------------------------------------------------------------------------------ + +test("single page definition, narrowed to www, not read-only, and agrees with the registry", () => { + const { definition } = harness(); + assert.equal(definition.kind, "page"); + assert.equal(definition.tool.name, "set_delivery_address"); + assert.deepEqual(plain(definition.match.url_patterns), ["https://www.doordash.com/*"]); + assert.equal(definition.tool.annotations.readOnlyHint, false); + const entry = registry.tools.find(t => t.domain === "doordash.com" && t.tool === "set_delivery_address"); + assert.equal(entry.kind, "page"); + assert.deepEqual(entry.url_patterns, ["https://www.doordash.com/*"]); +}); + +test("checks the visit is signed out, sets the first suggestion with the picker's three requests and reports DoorDash's confirmed address", async () => { + const h = harness(); + const result = await h.run({ address: " 1 Fabricated Plaza, Testville " }); + assert.equal(result.status, "ok"); + assert.deepEqual(result.address, { printable: "1 Fabricated Plaza, Testville, NY 10001, USA", street: "1 Fabricated Plaza", city: "Testville", state: "NY", zip_code: "10001", submarket: "Testville" }); + assert.equal(result.suggestions.length, 3); + assert.match(result.next_step, /list the tools again/); + assert.deepEqual(h.calls.map(c => c.url), [BUCKETING, AUTOCOMPLETE + "1%20Fabricated%20Plaza%2C%20Testville", CREATE, MUTATION]); + assert.equal(h.calls[0].init.body.query, "query consumerBucketing { consumer { isGuest } }"); + assert.equal(h.calls[1].init.method, undefined, "the lookup is a GET"); + assert.deepEqual(h.calls[2].init.body, { address_identifier: { _type: "source_place_id_request", source_place_id: "fabricated-source-place-1" } }); + const mutation = h.calls[3].init; + assert.equal(mutation.headers["x-csrftoken"], "fabricated-csrf"); + assert.equal(mutation.body.operationName, "addConsumerAddressV2"); + assert.deepEqual(mutation.body.variables, { googlePlaceId: "P000FAKE1", printableAddress: "Testville, NY 10001, USA", lat: 40.101, lng: -73.1, city: "Testville", state: "NY", zipCode: "10001", shortname: "1 Fabricated Plaza", addressId: "901" }); + assert.match(mutation.body.query, /^mutation addConsumerAddressV2\(/); +}); + +test("chooses the numbered suggestion, and returns the suggestions when that number is out of range", async () => { + const second = harness(); + assert.equal((await second.run({ address: "Fabricated Plaza", suggestion: 2 })).address.street, "2 Fabricated Plaza"); + const beyond = harness(); + const result = await beyond.run({ address: "Fabricated Plaza", suggestion: 4 }); + assert.equal(result.error_kind, "address_not_found"); + assert.equal(result.suggestions.length, 3); + assert.equal(beyond.calls.length, 2, "nothing is created or set"); +}); + +test("refuses bad input, other origins and signed-in visits before any request", async () => { + for (const input of [null, {}, { address: "ab" }, { address: "x".repeat(201) }, { address: "1 Plaza", suggestion: 0 }, { address: "1 Plaza", suggestion: 1.5 }, { address: "1 Plaza", extra: 1 }]) { + const h = harness(); + assert.equal((await h.run(input)).error_kind, "invalid_input", JSON.stringify(input)); + assert.equal(h.calls.length, 0); + } + const elsewhere = harness({ origin: "https://merchant-portal.doordash.com" }); + assert.equal((await elsewhere.run({ address: "1 Plaza" })).error_kind, "wrong_page"); + const signedIn = harness({ cookie: "dd_cx_logged_in=true; csrf_token=x" }); + const refused = await signedIn.run({ address: "1 Plaza" }); + assert.equal(refused.error_kind, "signed_in"); + assert.match(refused.error_message, /save_address/); + assert.equal(elsewhere.calls.length + signedIn.calls.length, 0); +}); + +test("an address with no suggestion is address_not_found and changes nothing", async () => { + const h = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 200, json: { predictions: [] } }) } }); + assert.equal((await h.run({ address: "Nowhere Fabricated" })).error_kind, "address_not_found"); + assert.equal(h.calls.length, 2); +}); + +test("site problems are reported, and an unconfirmed mutation says to check the page", async () => { + const limited = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 429, json: {} }) } }); + assert.equal((await limited.run({ address: "1 Plaza" })).error_kind, "rate_limited"); + const drifted = harness({ replies: { [AUTOCOMPLETE]: () => ({ status: 200, json: { results: [] } }) } }); + assert.equal((await drifted.run({ address: "1 Plaza" })).error_kind, "site_changed"); + const unresolved = harness({ replies: { [CREATE]: () => ({ status: 200, json: { address: place(1, { postal_code: "" }) } }) } }); + assert.equal((await unresolved.run({ address: "1 Plaza" })).error_kind, "site_changed"); + assert.equal(unresolved.calls.length, 3, "the mutation is not sent for an incomplete address"); + const rejected = harness({ replies: { [MUTATION]: () => ({ status: 200, json: { errors: [{ message: "nope" }], data: null } }) } }); + const failed = await rejected.run({ address: "1 Plaza" }); + assert.equal(failed.error_kind, "site_error"); + assert.match(failed.error_message, /check the header/); + const thrown = harness({ fetchError: new Error("offline") }); + assert.equal((await thrown.run({ address: "1 Plaza" })).error_kind, "site_error"); + const account = harness({ replies: { [MUTATION]: () => ({ status: 200, json: saved(1, { isGuest: false }) }) } }); + assert.equal((await account.run({ address: "1 Plaza" })).error_kind, "signed_in"); +}); + +test("a signed-in visit without the cookie is refused before any address request", async () => { + const account = harness({ replies: { [BUCKETING]: () => ({ status: 200, json: { data: { consumer: { isGuest: false } } } }) } }); + const refused = await account.run({ address: "1 Plaza" }); + assert.equal(refused.error_kind, "signed_in"); + assert.match(refused.error_message, /save_address/); + assert.deepEqual(account.calls.map(c => c.url), [BUCKETING]); + for (const reply of [{ status: 200, json: { data: { consumer: null } } }, { status: 200, json: { errors: [{ message: "x" }] } }, { status: 500, json: {} }]) { + const unclear = harness({ replies: { [BUCKETING]: () => reply } }); + assert.equal((await unclear.run({ address: "1 Plaza" })).error_kind, "site_changed", JSON.stringify(reply)); + assert.equal(unclear.calls.length, 1); + } + const limited = harness({ replies: { [BUCKETING]: () => ({ status: 429, json: {} }) } }); + assert.equal((await limited.run({ address: "1 Plaza" })).error_kind, "rate_limited"); +}); + +test("an answer naming a different address than the chosen suggestion is not success", async () => { + for (const changed of [{ zipCode: "10002" }, { city: "Elsewhere" }, { zipCode: null }, { street: "2 Fabricated Plaza" }, { street: null }]) { + const h = harness({ replies: { [MUTATION]: () => { const reply = saved(1); Object.assign(reply.data.addConsumerAddressV2.defaultAddress, changed); return { status: 200, json: reply }; } } }); + const result = await h.run({ address: "1 Plaza" }); + assert.equal(result.error_kind, "address_mismatch", JSON.stringify(changed)); + assert.match(result.error_message, /check the header/); + assert.equal(result.suggestions.length, 3); + } + const caseOnly = harness({ replies: { [MUTATION]: () => { const reply = saved(1); reply.data.addConsumerAddressV2.defaultAddress.city = "TESTVILLE "; return { status: 200, json: reply }; } } }); + assert.equal((await caseOnly.run({ address: "1 Plaza" })).status, "ok"); +});