diff --git a/src/checkout/paypal-verify.ts b/src/checkout/paypal-verify.ts new file mode 100644 index 0000000..f0eefae --- /dev/null +++ b/src/checkout/paypal-verify.ts @@ -0,0 +1,104 @@ +// Checks PayPal's answers against the shop order before the order is marked paid: the PayPal order the buyer approved +// (GET /v2/checkout/orders/:id) and the order after its capture (POST /v2/checkout/orders/:id/capture, Prefer: return=representation). +// The JSON is PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the raw response body. Money is compared in integer cents. +// A reason says what does not match. It repeats only PayPal's statuses, currency codes and amounts, never a name, an email address +// or an address from the response, because it is logged. + +/** The fields of the shop order the checks compare. */ +export type VerifiableOrder = { + uuid: string; + total: number | null; +}; + +export type ApprovedOrderCheck = { ok: true } | { ok: false; reason: string }; + +export type CompletedCaptureCheck = { ok: true; captureId: string } | { ok: false; reason: string }; + +const CURRENCY = "EUR"; + +type Json = Record; + +const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value); + +/** PayPal's money value as integer cents: "12.34" is 1234. Null for anything but a non-negative amount with at most two decimals. */ +export const parseCents = (value: unknown): number | null => { + if (typeof value !== "string") return null; + const match = /^(\d{1,10})(?:\.(\d{1,2}))?$/.exec(value); + if (match === null) return null; + return Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0")); +}; + +/** The order total in integer cents, or null if the order has no positive total. Strapi returns the decimal column as a number. */ +export const orderTotalCents = (order: { total: number | null }): number | null => + typeof order.total === "number" && Number.isFinite(order.total) && order.total > 0 ? Math.round(order.total * 100) : null; + +const formatCents = (cents: number): string => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`; + +// The values of OrderStatus and CaptureStatus in @paypal/paypal-server-sdk 0.6.1. A reason repeats these and ISO currency codes, +// and no other value from the response. +const ORDER_STATUSES = ["CREATED", "SAVED", "APPROVED", "VOIDED", "COMPLETED", "PAYER_ACTION_REQUIRED"]; +const CAPTURE_STATUSES = ["COMPLETED", "DECLINED", "PARTIALLY_REFUNDED", "PENDING", "REFUNDED", "FAILED"]; + +const shown = (value: unknown, isKnown: (value: string) => boolean): string => + typeof value === "string" && isKnown(value) ? value : "missing or unexpected"; +const orderStatus = (value: unknown): string => shown(value, (status) => ORDER_STATUSES.includes(status)); +const captureStatus = (value: unknown): string => shown(value, (status) => CAPTURE_STATUSES.includes(status)); +const currency = (value: unknown): string => shown(value, (code) => /^[A-Z]{3}$/.test(code)); + +const firstPurchaseUnit = (paypalOrder: Json): Json | null => { + const units = paypalOrder.purchase_units; + return Array.isArray(units) && isObject(units[0]) ? units[0] : null; +}; + +/** Why PayPal's money ({ currency_code, value }) is not the order total in EUR, or null if it is. */ +const amountMismatch = (subject: string, money: unknown, order: VerifiableOrder): string | null => { + if (!isObject(money)) return `${subject} has no amount`; + if (money.currency_code !== CURRENCY) return `${subject} currency is ${currency(money.currency_code)}, not ${CURRENCY}`; + const cents = parseCents(money.value); + if (cents === null) return `${subject} amount is not a money value`; + const total = orderTotalCents(order); + if (total === null) return "the order has no total"; + if (cents !== total) return `${subject} amount ${formatCents(cents)} is not the order total ${formatCents(total)}`; + return null; +}; + +/** + * The PayPal order may be captured for this order: the buyer approved it, it was created for this order (its custom_id is the + * order's uuid, see buildPayPalOrderRequest), and its amount is the order total in EUR. + */ +export const verifyApprovedOrder = (order: VerifiableOrder, paypalOrder: unknown): ApprovedOrderCheck => { + if (!isObject(paypalOrder)) return { ok: false, reason: "the PayPal order is not an object" }; + if (paypalOrder.status !== "APPROVED") { + return { ok: false, reason: `the PayPal order status is ${orderStatus(paypalOrder.status)}, not APPROVED` }; + } + + const unit = firstPurchaseUnit(paypalOrder); + if (unit === null) return { ok: false, reason: "the PayPal order has no purchase unit" }; + if (typeof order.uuid !== "string" || order.uuid === "" || unit.custom_id !== order.uuid) { + return { ok: false, reason: "the custom_id of the PayPal order is not the order's uuid" }; + } + + const mismatch = amountMismatch("the PayPal order", unit.amount, order); + return mismatch === null ? { ok: true } : { ok: false, reason: mismatch }; +}; + +/** + * PayPal captured the order total in EUR: the PayPal order and its first capture are COMPLETED. Returns the capture's id. + * A capture PayPal holds for review (PENDING) or declined (DECLINED) is not a payment. + */ +export const verifyCompletedCapture = (order: VerifiableOrder, captured: unknown): CompletedCaptureCheck => { + if (!isObject(captured)) return { ok: false, reason: "the capture response is not an object" }; + if (captured.status !== "COMPLETED") return { ok: false, reason: `the PayPal order status is ${orderStatus(captured.status)}, not COMPLETED` }; + + const unit = firstPurchaseUnit(captured); + const payments = unit !== null && isObject(unit.payments) ? unit.payments : null; + const capture = payments !== null && Array.isArray(payments.captures) && isObject(payments.captures[0]) ? payments.captures[0] : null; + if (capture === null) return { ok: false, reason: "the capture response has no capture" }; + if (capture.status !== "COMPLETED") return { ok: false, reason: `the capture status is ${captureStatus(capture.status)}, not COMPLETED` }; + + const mismatch = amountMismatch("the capture", capture.amount, order); + if (mismatch !== null) return { ok: false, reason: mismatch }; + if (typeof capture.id !== "string" || !/^[A-Za-z0-9-]{1,64}$/.test(capture.id)) return { ok: false, reason: "the capture has no valid id" }; + + return { ok: true, captureId: capture.id }; +}; diff --git a/tests/unit/fixtures/paypal-order-approved.json b/tests/unit/fixtures/paypal-order-approved.json new file mode 100644 index 0000000..5715338 --- /dev/null +++ b/tests/unit/fixtures/paypal-order-approved.json @@ -0,0 +1,88 @@ +{ + "id": "5O190127TN364715T", + "intent": "CAPTURE", + "status": "APPROVED", + "payment_source": { + "paypal": { + "email_address": "erika.buyer@example.org", + "account_id": "QYR5Z8XDVJNXQ", + "account_status": "VERIFIED", + "name": { + "given_name": "Erika", + "surname": "Mustermann" + }, + "address": { + "country_code": "DE" + } + } + }, + "purchase_units": [ + { + "reference_id": "11111111-2222-4333-8444-555555555555", + "amount": { + "currency_code": "EUR", + "value": "22.85", + "breakdown": { + "item_total": { + "currency_code": "EUR", + "value": "15.04" + }, + "shipping": { + "currency_code": "EUR", + "value": "4.95" + }, + "tax_total": { + "currency_code": "EUR", + "value": "2.86" + } + } + }, + "payee": { + "email_address": "merchant@example.com", + "merchant_id": "7KNGBPH2U58GQ" + }, + "custom_id": "11111111-2222-4333-8444-555555555555", + "invoice_id": "R-12-4711", + "shipping": { + "name": { + "full_name": "Erika Mustermann" + }, + "address": { + "address_line_1": "Musterstraße 1", + "admin_area_2": "Stuttgart", + "postal_code": "70190", + "country_code": "DE" + } + } + } + ], + "payer": { + "name": { + "given_name": "Erika", + "surname": "Mustermann" + }, + "email_address": "erika.buyer@example.org", + "payer_id": "QYR5Z8XDVJNXQ", + "address": { + "country_code": "DE" + } + }, + "create_time": "2026-10-08T19:12:40Z", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "update", + "method": "PATCH" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture", + "rel": "capture", + "method": "POST" + } + ] +} diff --git a/tests/unit/fixtures/paypal-order-captured.json b/tests/unit/fixtures/paypal-order-captured.json new file mode 100644 index 0000000..df157da --- /dev/null +++ b/tests/unit/fixtures/paypal-order-captured.json @@ -0,0 +1,131 @@ +{ + "id": "5O190127TN364715T", + "intent": "CAPTURE", + "status": "COMPLETED", + "payment_source": { + "paypal": { + "email_address": "erika.buyer@example.org", + "account_id": "QYR5Z8XDVJNXQ", + "account_status": "VERIFIED", + "name": { + "given_name": "Erika", + "surname": "Mustermann" + }, + "address": { + "country_code": "DE" + } + } + }, + "purchase_units": [ + { + "reference_id": "11111111-2222-4333-8444-555555555555", + "amount": { + "currency_code": "EUR", + "value": "22.85", + "breakdown": { + "item_total": { + "currency_code": "EUR", + "value": "15.04" + }, + "shipping": { + "currency_code": "EUR", + "value": "4.95" + }, + "tax_total": { + "currency_code": "EUR", + "value": "2.86" + } + } + }, + "payee": { + "email_address": "merchant@example.com", + "merchant_id": "7KNGBPH2U58GQ" + }, + "custom_id": "11111111-2222-4333-8444-555555555555", + "invoice_id": "R-12-4711", + "shipping": { + "name": { + "full_name": "Erika Mustermann" + }, + "address": { + "address_line_1": "Musterstraße 1", + "admin_area_2": "Stuttgart", + "postal_code": "70190", + "country_code": "DE" + } + }, + "payments": { + "captures": [ + { + "id": "3C679366HH908993F", + "status": "COMPLETED", + "amount": { + "currency_code": "EUR", + "value": "22.85" + }, + "final_capture": true, + "seller_protection": { + "status": "ELIGIBLE", + "dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"] + }, + "seller_receivable_breakdown": { + "gross_amount": { + "currency_code": "EUR", + "value": "22.85" + }, + "paypal_fee": { + "currency_code": "EUR", + "value": "0.92" + }, + "net_amount": { + "currency_code": "EUR", + "value": "21.93" + } + }, + "custom_id": "11111111-2222-4333-8444-555555555555", + "invoice_id": "R-12-4711", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "up", + "method": "GET" + } + ], + "create_time": "2026-10-08T19:13:02Z", + "update_time": "2026-10-08T19:13:02Z" + } + ] + } + } + ], + "payer": { + "name": { + "given_name": "Erika", + "surname": "Mustermann" + }, + "email_address": "erika.buyer@example.org", + "payer_id": "QYR5Z8XDVJNXQ", + "address": { + "country_code": "DE" + } + }, + "create_time": "2026-10-08T19:12:40Z", + "update_time": "2026-10-08T19:13:02Z", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel": "self", + "method": "GET" + } + ] +} diff --git a/tests/unit/paypal-verify.test.ts b/tests/unit/paypal-verify.test.ts new file mode 100644 index 0000000..10222aa --- /dev/null +++ b/tests/unit/paypal-verify.test.ts @@ -0,0 +1,254 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts"; +import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts"; + +const UUID = "11111111-2222-4333-8444-555555555555"; + +// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it +// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with +// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change. +const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8")); +const approved = () => fixture("paypal-order-approved.json"); +const captured = () => fixture("paypal-order-captured.json"); + +// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85. +const order = (overrides = {}) => ({ + id: 4711, + uuid: UUID, + email: "erika@example.org", + subtotal: 17.9, + total: 22.85, + VAT: 2.86, + delivery: { id: 1, name: "DHL", price: 4.95 }, + invoiceNumber: "R-12-4711", + deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart", + ...overrides +}); + +// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it. +const PERSONAL_DATA = [ + "Erika", + "Mustermann", + "erika", + "QYR5Z8XDVJNXQ", + "Musterstraße", + "70190", + "Stuttgart", + "merchant@example.com", + "7KNGBPH2U58GQ" +]; + +/** The reason of a rejection, after checking that it repeats no personal data. */ +const rejection = (result: { ok: boolean; reason?: string }): string => { + assert.equal(result.ok, false, "expected a rejection"); + for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`); + return result.reason; +}; + +// Money + +test("parses PayPal's money values into integer cents", () => { + assert.equal(parseCents("22.85"), 2285); + assert.equal(parseCents("0.01"), 1); + assert.equal(parseCents("4.9"), 490); + assert.equal(parseCents("100"), 10000); + assert.equal(parseCents("0.00"), 0); + assert.equal(parseCents("1234567.89"), 123456789); +}); + +test("parses nothing but a non-negative amount with at most two decimals", () => { + for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) { + assert.equal(parseCents(value), null, JSON.stringify(value)); + } + for (const value of [22.85, null, undefined, {}, ["22.85"]]) { + assert.equal(parseCents(value), null, String(value)); + } +}); + +test("turns the order total into integer cents, also where its float is a hair below the cent", () => { + // 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point + assert.equal(orderTotalCents({ total: 22.85 }), 2285); + assert.equal(orderTotalCents({ total: 0.29 }), 29); + assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285); + for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) { + assert.equal(orderTotalCents({ total }), null, String(total)); + } +}); + +test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => { + const mismatches: number[] = []; + for (let cents = 1; cents <= 300000; cents++) { + const total = cents / 100; + // buildPayPalOrderRequest sends order.total.toFixed(2); the order service rounds every total to the cent + if (parseCents(total.toFixed(2)) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total); + } + assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`); +}); + +test("the fixtures belong to the PayPal order the checkout requests for the order", () => { + const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0]; + + for (const paypalOrder of [approved(), captured()]) { + const unit = paypalOrder.purchase_units[0]; + assert.equal(unit.custom_id, request.customId); + assert.equal(unit.amount.value, request.amount.value); + assert.equal(unit.amount.currency_code, request.amount.currencyCode); + } +}); + +// The PayPal order the buyer approved + +test("accepts the PayPal order the buyer approved for the order", () => { + assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true }); +}); + +test("rejects a PayPal order that is not approved", () => { + for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) { + assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`); + } + const { status, ...withoutStatus } = approved(); + assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED"); + // a value that is not one of PayPal's statuses is not repeated + assert.equal( + rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })), + "the PayPal order status is missing or unexpected, not APPROVED" + ); +}); + +test("rejects the PayPal order of a cheap order captured for an expensive one", () => { + // The buyer pays order A, then calls the capture of order B with A's PayPal order. + const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 }); + + assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid"); +}); + +test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => { + const paypalOrder = approved(); + delete paypalOrder.purchase_units[0].custom_id; + + assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid"); + // undefined is not compared with undefined, nor "" with "" + assert.equal( + rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)), + "the custom_id of the PayPal order is not the order's uuid" + ); + paypalOrder.purchase_units[0].custom_id = ""; + assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid"); +}); + +test("rejects a PayPal order whose amount is not the order total", () => { + const withAmount = (value: unknown) => { + const paypalOrder = approved(); + paypalOrder.purchase_units[0].amount.value = value; + return paypalOrder; + }; + + assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85"); + assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85"); + assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85"); + // the cart changed after the checkout + assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00"); + for (const value of ["22.850", "22,85", "", 22.85, null]) { + assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value)); + } +}); + +test("rejects a PayPal order in another currency or without an amount", () => { + const paypalOrder = approved(); + paypalOrder.purchase_units[0].amount.currency_code = "USD"; + assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR"); + + paypalOrder.purchase_units[0].amount.currency_code = "Euro"; + assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR"); + + delete paypalOrder.purchase_units[0].amount; + assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount"); +}); + +test("rejects every PayPal order for an order without a total", () => { + for (const total of [null, 0]) { + assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total"); + } +}); + +test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => { + assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit"); + const { purchase_units, ...withoutUnits } = approved(); + assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit"); + + // "/result/error" is what PayPalApi.checkPaymentStatus answered for every error + for (const value of [null, undefined, "/result/error", [], 42]) { + assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value)); + } +}); + +// The PayPal order after the capture + +test("accepts the completed capture of the order total and returns the capture id", () => { + assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" }); +}); + +test("rejects a capture response whose PayPal order is not completed", () => { + for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) { + assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`); + } +}); + +test("rejects a capture PayPal holds for review, declined or refunded", () => { + for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) { + const response = captured(); + response.purchase_units[0].payments.captures[0].status = status; + if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" }; + + assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`); + } +}); + +test("rejects a capture response without a capture", () => { + // what PayPal answers with Prefer: return=minimal, which the capture used to ask for + const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links }; + assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture"); + + const withoutPayments = captured(); + delete withoutPayments.purchase_units[0].payments; + assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture"); + + const withoutCaptures = captured(); + withoutCaptures.purchase_units[0].payments.captures = []; + assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture"); + + assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture"); +}); + +test("rejects a capture of another amount or in another currency", () => { + const lower = captured(); + lower.purchase_units[0].payments.captures[0].amount.value = "2.29"; + assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85"); + + // the cart changed during the capture + assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00"); + + const usd = captured(); + usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD"; + assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR"); + + const withoutAmount = captured(); + delete withoutAmount.purchase_units[0].payments.captures[0].amount; + assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount"); +}); + +test("rejects a capture without a valid id", () => { + for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) { + const response = captured(); + response.purchase_units[0].payments.captures[0].id = id; + assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id)); + } +}); + +test("rejects anything that is not a capture response", () => { + for (const value of [null, undefined, "/result/error", [], 42]) { + assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value)); + } +});