diff --git a/tests/pw/mu-plugins/dokan-stripe-connect-test-helpers.php b/tests/pw/mu-plugins/dokan-stripe-connect-test-helpers.php new file mode 100644 index 0000000000..fefcb80ba0 --- /dev/null +++ b/tests/pw/mu-plugins/dokan-stripe-connect-test-helpers.php @@ -0,0 +1,109 @@ + 'POST', + 'permission_callback' => function () { + return current_user_can( 'manage_woocommerce' ); + }, + 'callback' => function ( WP_REST_Request $request ) { + if ( ! function_exists( 'dokan_pro' ) || empty( dokan_pro()->refund ) ) { + return new WP_Error( 'dokan_test_no_refund_mgr', 'Dokan Pro refund manager unavailable', [ 'status' => 500 ] ); + } + + $order_id = absint( $request->get_param( 'order_id' ) ); + $order = wc_get_order( $order_id ); + + if ( ! $order instanceof WC_Order ) { + return new WP_Error( 'dokan_test_no_order', 'Order not found', [ 'status' => 404 ] ); + } + + // Mark completed so the refund is approvable per the Withdraw order-status setting. + if ( ! in_array( $order->get_status(), [ 'completed', 'processing' ], true ) ) { + $order->update_status( 'completed', 'E2E: ready for refund' ); + } + + $item_qtys = []; + $item_totals = []; + $item_tax_totals = []; + + $collect = function ( $item_id, $item ) use ( &$item_totals, &$item_tax_totals ) { + $item_totals[ $item_id ] = wc_format_decimal( $item->get_total(), 2 ); + $taxes = $item->get_taxes(); + if ( ! empty( $taxes['total'] ) ) { + foreach ( $taxes['total'] as $rate_id => $amount ) { + $item_tax_totals[ $item_id ][ $rate_id ] = wc_format_decimal( $amount, 2 ); + } + } + }; + + foreach ( $order->get_items() as $item_id => $item ) { + $item_qtys[ $item_id ] = $item->get_quantity(); + $collect( $item_id, $item ); + } + foreach ( $order->get_items( 'shipping' ) as $ship_id => $ship ) { + $collect( $ship_id, $ship ); + } + + // Optional partial refund: refund `amount` against the first line item. + $amount = $request->get_param( 'amount' ); + if ( $amount !== null && $amount !== '' ) { + $first_item_id = array_key_first( $order->get_items() ); + $args = [ + 'order_id' => $order_id, + 'refund_amount' => wc_format_decimal( $amount, 2 ), + 'refund_reason' => 'e2e stripe connect partial refund', + 'item_qtys' => [], + 'item_totals' => [ $first_item_id => wc_format_decimal( $amount, 2 ) ], + 'item_tax_totals' => [], + 'restock_items' => 'false', + 'method' => '1', + ]; + } else { + $args = [ + 'order_id' => $order_id, + 'refund_amount' => wc_format_decimal( $order->get_total(), 2 ), + 'refund_reason' => 'e2e stripe connect full refund', + 'item_qtys' => $item_qtys, + 'item_totals' => $item_totals, + 'item_tax_totals' => $item_tax_totals, + 'restock_items' => 'true', + 'method' => '1', // 1 = API refund → process_3ds_refund (Stripe refund + transfer reversal). + ]; + } + + $result = dokan_pro()->refund->create( $args ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( 'dokan_test_refund_failed', $result->get_error_message(), [ 'status' => 400 ] ); + } + + return rest_ensure_response( + [ + 'ok' => true, + 'order_id' => $order_id, + 'refund_amount' => (float) $order->get_total(), + ] + ); + }, + ] + ); + } +); diff --git a/tests/pw/tests/e2e/stripe-connect/stripeConnect.spec.ts b/tests/pw/tests/e2e/stripe-connect/stripeConnect.spec.ts new file mode 100644 index 0000000000..fdfe2a9e6b --- /dev/null +++ b/tests/pw/tests/e2e/stripe-connect/stripeConnect.spec.ts @@ -0,0 +1,928 @@ +import { test, expect, request, Page, BrowserContext } from '@utils/test'; +import { log } from '@utils/logger'; +import { dbUtils } from '@utils/dbUtils'; +import { ApiUtils } from '@utils/apiUtils'; +import { payloads } from '@utils/payloads'; +import { stripeApi } from '@utils/stripeApi'; +import { StripeConnectPage, STRIPE_CONNECT_KEYS, STRIPE_CONNECTED_ACCOUNTS, HAS_REAL_CONNECTED_ACCOUNTS, STRIPE_CARDS } from './stripeConnectPage'; +import path from 'path'; + +declare const process: { env: Record }; + +const adminAuth = path.join(__dirname, '../../../playwright/.auth/adminStorageState.json'); +const vendorAuth = path.join(__dirname, '../../../playwright/.auth/vendorStorageState.json'); +const customerAuth = path.join(__dirname, '../../../playwright/.auth/customerStorageState.json'); +const VENDOR_ID = process.env.VENDOR_ID ?? '3'; +const VENDOR2_ID = process.env.VENDOR2_ID ?? '5'; +const CUSTOMER_ID = process.env.CUSTOMER_ID ?? '2'; +const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:9999/wp-json'; + +/** + * Trigger a full API Dokan refund for an order via the test-support mu-plugin + * (`dokan-test/v1/refund`). This fires `dokan_refund_request_created` with + * method=1, which runs the gateway's process_3ds_refund → Stripe refund + vendor + * transfer reversal. There is no public REST endpoint to create a Dokan refund. + */ +async function dokanApiRefund(orderId: string | number, amount?: number): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + const data: Record = { order_id: Number(orderId) }; + if (amount !== undefined) { + data.amount = amount; + } + const res = await ctx.post(`${SERVER_URL}/dokan-test/v1/refund`, { data }); + const body = await res.json().catch(() => ({})); + if (!res.ok()) { + throw new Error(`Dokan API refund failed (${res.status()}): ${JSON.stringify(body)}`); + } + } finally { + await ctx.dispose(); + } +} + +/** + * Assert the newest Stripe Connect order settled to a paid status. Used by the + * 3DS tests: completing the SCA challenge settles the ORDER server-side, but the + * browser doesn't reliably redirect to order-received in automation — so we + * verify the order status, not the URL. + */ +/** Newest dokan-stripe-connect order id (0 if none) — a baseline to pin against. */ +async function getLatestStripeOrderId(): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + const res = await ctx.get(`${SERVER_URL}/wc/v3/orders?per_page=10&orderby=date&order=desc&_fields=id,payment_method`); + const orders = (await res.json().catch(() => [])) as Array<{ id: number; payment_method: string }>; + const o = Array.isArray(orders) ? orders.find(x => x.payment_method === 'dokan-stripe-connect') : undefined; + return o ? Number(o.id) : 0; + } finally { + await ctx.dispose(); + } +} + +/** + * Assert that a NEW Stripe Connect order (id > baseline, i.e. created by this + * test) settled to a paid status. Pinning to "newer than baseline" prevents a + * false PASS by re-reading a prior test's already-settled order — important for + * the 3DS tests, where the browser redirect to order-received is unreliable. + */ +async function assertStripeOrderSettledSince(baselineOrderId: number): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + await expect + .poll( + async () => { + const res = await ctx.get(`${SERVER_URL}/wc/v3/orders?per_page=10&orderby=date&order=desc&_fields=id,status,payment_method`); + const orders = (await res.json().catch(() => [])) as Array<{ id: number; status: string; payment_method: string }>; + const o = Array.isArray(orders) + ? orders.find(x => x.payment_method === 'dokan-stripe-connect' && Number(x.id) > baselineOrderId) + : undefined; + return o ? o.status : 'none'; + }, + { message: 'a NEW Stripe order (created by this test) should settle to a paid status after SCA', timeout: 60_000 }, + ) + .toMatch(/processing|completed/); + } finally { + await ctx.dispose(); + } +} + +/** Resolve the Stripe charge id for a WC order (via its PaymentIntent meta). */ +async function getStripeChargeIdForOrder(orderId: string): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + const res = await ctx.get(`${SERVER_URL}/wc/v3/orders/${orderId}?_fields=meta_data`); + const body = (await res.json()) as { meta_data: Array<{ key: string; value: string }> }; + const intentId = body.meta_data.find(m => m.key === '_stripe_intent_id' || m.key === 'dokan_stripe_intent_id')?.value; + if (!intentId) { + throw new Error(`order ${orderId} has no Stripe PaymentIntent meta`); + } + return await stripeApi.getLatestChargeId(String(intentId)); + } finally { + await ctx.dispose(); + } +} + +/** Resolve the Stripe PaymentIntent id for a WC order (from its meta). */ +async function getStripeIntentIdForOrder(orderId: string): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + const res = await ctx.get(`${SERVER_URL}/wc/v3/orders/${orderId}?_fields=meta_data`); + const body = (await res.json()) as { meta_data: Array<{ key: string; value: string }> }; + const intentId = body.meta_data.find(m => m.key === '_stripe_intent_id' || m.key === 'dokan_stripe_intent_id')?.value; + if (!intentId) { + throw new Error(`order ${orderId} has no Stripe PaymentIntent meta`); + } + return String(intentId); + } finally { + await ctx.dispose(); + } +} + +const WEBHOOK_URL = `${(process.env.BASE_URL ?? 'http://localhost:9999').replace(/\/$/, '')}/?wc-api=dokan_stripe`; + +/** + * POST a Stripe event to the gateway webhook endpoint, UNSIGNED (the handler does + * no signature verification — R2 — and re-fetches the event by id). Returns the + * HTTP status. Used to simulate webhook delivery + replay. + */ +async function postWebhookEvent(event: { id: string; type: string; account?: string | null }): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: { Authorization: '' } }); + try { + const res = await ctx.post(WEBHOOK_URL, { data: { id: event.id, type: event.type, account: event.account ?? null } }); + return res.status(); + } finally { + await ctx.dispose(); + } +} + +/** Give the customer a saved billing+shipping address so the block checkout pre-fills. */ +async function ensureCustomerAddress(): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + await ctx.put(`${SERVER_URL}/wc/v3/customers/${CUSTOMER_ID}`, { + data: { billing: payloads.createOrder.billing, shipping: payloads.createOrder.shipping }, + }); + } finally { + await ctx.dispose(); + } +} + +/** Ensure a [woocommerce_checkout] shortcode page exists at /classic-checkout/ (idempotent). */ +async function ensureClassicCheckoutPage(): Promise { + const ctx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + const res = await ctx.get(`${SERVER_URL}/wp/v2/pages?slug=classic-checkout&status=publish`); + const pages = (await res.json().catch(() => [])) as unknown[]; + if (!Array.isArray(pages) || pages.length === 0) { + await ctx.post(`${SERVER_URL}/wp/v2/pages`, { + data: { title: 'Classic Checkout', slug: 'classic-checkout', status: 'publish', content: '[woocommerce_checkout]' }, + }); + } + } finally { + await ctx.dispose(); + } +} + +const MODULE_STRIPE = 'stripe'; +const MODULE_STRIPE_EXPRESS = 'stripe_express'; + +// Credentials gate. Building the suite never requires keys; running the +// gateway/vendor steps does. `hasCredentials` => can configure the gateway. +// `isReadyCapable` => gateway can become "ready" (needs a client id for the +// SAME Connect account), which is what surfaces the vendor connect button. +const hasCredentials = Boolean(STRIPE_CONNECT_KEYS.publishable && STRIPE_CONNECT_KEYS.secret); +const isReadyCapable = hasCredentials && Boolean(STRIPE_CONNECT_KEYS.clientId); + +/** + * Pre-setup that the Stripe Connect checkout/money specs depend on. Ordered + * (describe.serial): disable the conflicting Stripe Express module → ensure the + * Stripe Connect module is active → configure the gateway with test keys → save. + * All steps are idempotent so re-runs are safe. + */ +test.describe.serial('Stripe Connect — pre-setup (admin gateway configuration)', () => { + let aPage: Page; + let aCtx: BrowserContext; + let stripe: StripeConnectPage; + + test.beforeAll(async ({ browser }) => { + aCtx = await browser.newContext({ storageState: adminAuth }); + aPage = await aCtx.newPage(); + stripe = new StripeConnectPage(aPage); + }); + + test.afterAll(async () => { + await aPage?.close(); + await aCtx?.close(); + }); + + test('admin can disable Stripe Express module', { tag: ['@pro', '@admin'] }, async () => { + await stripe.gotoModules(); + if (!(await stripe.isModuleEnabled(MODULE_STRIPE_EXPRESS))) { + log.skip('Stripe Express module already disabled — skipping the toggle'); + } + await stripe.setModuleEnabled(MODULE_STRIPE_EXPRESS, false); // idempotent: only toggles if needed + expect(await stripe.isModuleEnabled(MODULE_STRIPE_EXPRESS), 'Stripe Express must be disabled').toBe(false); + log.success('Stripe Express module is disabled'); + }); + + test('admin can enable Stripe Connect module', { tag: ['@pro', '@admin'] }, async () => { + await stripe.gotoModules(); + if (await stripe.isModuleEnabled(MODULE_STRIPE)) { + log.skip('Stripe Connect module already enabled — skipping the toggle'); + } + await stripe.setModuleEnabled(MODULE_STRIPE, true); // idempotent + expect(await stripe.isModuleEnabled(MODULE_STRIPE), 'Stripe Connect must be enabled').toBe(true); + log.success('Stripe Connect module is enabled'); + }); + + test('admin can add Stripe Connect payment method', { tag: ['@pro', '@admin'] }, async () => { + test.skip( + !hasCredentials, + 'Stripe Connect test keys missing — set TEST_PUBLISH_KEY_STRIPE_CONNECT / TEST_SECRET_KEY_STRIPE_CONNECT in tests/pw/.env', + ); + await stripe.configureGateway({ + publishable: STRIPE_CONNECT_KEYS.publishable, + secret: STRIPE_CONNECT_KEYS.secret, + clientId: STRIPE_CONNECT_KEYS.clientId, // empty unless provided → gateway stays "almost ready" + enable: true, + testmode: true, + savedCards: true, + }); + await stripe.assertGatewayConfigured({ enabled: true, testmode: true }); + log.success('Stripe Connect gateway enabled with test credentials (test mode)'); + }); + + test('Stripe Connect 3D Secure / SCA field is disabled', { tag: ['@pro', '@admin'] }, async () => { + await stripe.assert3dSecureFieldDisabled(); + log.success('3D Secure / SCA field is rendered disabled (SCA is unconditional)'); + }); + + test('gateway readiness reflects client-id presence', { tag: ['@pro', '@admin'] }, async () => { + test.skip(!hasCredentials, 'Gateway not configured — test keys missing'); + if (isReadyCapable) { + await stripe.assertGatewayReady(); + log.success('Gateway reports ready (client id present)'); + } else { + await stripe.assertGatewayAlmostReady(); + log.warn( + 'Gateway "almost ready"', + 'no TEST_CLIENT_ID_STRIPE_CONNECT set → Stripe Connect withdraw method + vendor connect button will not appear', + ); + } + }); + + test('admin can enable Stripe Connect as a vendor withdraw method', { tag: ['@pro', '@admin'] }, async () => { + test.skip( + !isReadyCapable, + 'Gateway not ready — needs TEST_CLIENT_ID_STRIPE_CONNECT; the withdraw method only registers once ready', + ); + // Enabling this in Dokan settings → Withdraw Options is what surfaces the + // connect button in the vendor dashboard (idempotent). + await stripe.setStripeWithdrawMethodEnabled(true); + log.success('Stripe Connect enabled as a vendor withdraw method'); + }); +}); + +/** + * Vendor onboarding. Gated on the gateway being "ready" (needs a client id for + * the same Connect account). The OAuth round-trip itself is Stripe-hosted and + * not automatable — these assert the connect UI surfaces and initiates OAuth; + * an actually-connected vendor for money tests is seeded via user meta. + */ +test.describe.serial('Stripe Connect — vendor onboarding', () => { + let vPage: Page; + let vCtx: BrowserContext; + let stripe: StripeConnectPage; + + test.beforeAll(async ({ browser }) => { + vCtx = await browser.newContext({ storageState: vendorAuth }); + vPage = await vCtx.newPage(); + stripe = new StripeConnectPage(vPage); + }); + + test.afterAll(async () => { + await vPage?.close(); + await vCtx?.close(); + }); + + test('vendor can add Stripe Connect payment method', { tag: ['@pro', '@vendor'] }, async () => { + test.skip( + !isReadyCapable, + 'Gateway not ready — set TEST_CLIENT_ID_STRIPE_CONNECT (same Connect account as the keys); the connect button does not render until the gateway is ready', + ); + if (await stripe.isVendorConnected()) { + log.skip('Vendor already connected to Stripe — skipping connect-button assertion'); + return; + } + await stripe.assertConnectButtonVisible(); + log.success('Vendor Stripe connect button is visible'); + }); + + test('vendor connect button initiates Stripe OAuth', { tag: ['@pro', '@vendor'] }, async () => { + test.skip(!isReadyCapable, 'Gateway not ready — set TEST_CLIENT_ID_STRIPE_CONNECT to test the OAuth redirect'); + test.skip(await stripe.isVendorConnected(), 'Vendor already connected — disconnect to re-test the OAuth redirect'); + await stripe.clickConnectExpectStripeRedirect(); + log.success('Connect button redirected to Stripe OAuth (round-trip completion is manual/seeded)'); + }); +}); + +/** + * Establishing the connected vendor state WITHOUT the Stripe OAuth login, by + * seeding the same user-meta the OAuth callback writes. This is the precondition + * the checkout/money specs use. Self-contained: seeds, asserts, then restores + * the unconnected state so it doesn't affect the onboarding tests above. + * + * A placeholder acct_ is enough to show "connected" + unblock checkout; real + * transfer assertions need a genuine Stripe test connected account (acct_). + */ +test.describe.serial('Stripe Connect — vendor connection seeded via DB (no OAuth)', () => { + const seededAccount = 'acct_seeded_e2e_vendor'; + + test('vendor can be connected by DB seed and shows the connected UI', { tag: ['@pro', '@vendor'] }, async ({ browser }) => { + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: seededAccount }); + const ctx = await browser.newContext({ storageState: vendorAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await stripe.assertVendorConnectedUI(seededAccount); // Disconnect button + Merchant ID + log.success('Vendor seeded as connected via DB — Disconnect UI + Merchant ID shown, no OAuth'); + } finally { + await page.close(); + await ctx.close(); + // restore the unconnected state for the onboarding tests / re-runs + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + } + }); +}); + +/** + * Classic checkout (Payment Elements). Precondition: gateway configured (handled + * by the admin pre-setup describe above, run earlier in this file) + a connected + * vendor + a purchasable vendor product. Classic = a [woocommerce_checkout] + * shortcode page (the site default /checkout/ is the WC Checkout block). + */ +test.describe.serial('Stripe Connect — classic checkout (Payment Elements)', () => { + test.describe.configure({ timeout: 120_000 }); // real card entry + Stripe confirm/redirect is slow + let productId: string; + + test.beforeAll(async () => { + await ensureClassicCheckoutPage(); + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor1 }); + const api = new ApiUtils(await request.newContext()); + [, productId] = await api.createProduct(payloads.createProduct(), payloads.vendorAuth); + }); + + test.afterAll(async () => { + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + }); + + test('customer can buy product using Stripe Connect payment method', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoClassicCheckout(); + await stripe.fillBillingClassic(); + await stripe.selectClassicGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeClassicOrderExpectReceived(); + log.success('Customer paid with Stripe Connect on classic checkout — order received'); + } finally { + await page.close(); + await ctx.close(); + } + }); + + test('classic checkout: declined card shows an error and creates no order', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoClassicCheckout(); + await stripe.fillBillingClassic(); + await stripe.selectClassicGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.declined); + await stripe.placeClassicOrderExpectError(); + log.success('Declined card surfaced an inline error and created no order'); + } finally { + await page.close(); + await ctx.close(); + } + }); +}); + +/** + * Block checkout (WC Cart & Checkout blocks — the site default /checkout/). + * The customer is given a saved address (ensureCustomerAddress) so the block + * pre-fills contact/shipping and the payment step + place-order are reachable. + */ +test.describe.serial('Stripe Connect — block checkout (Cart & Checkout Blocks)', () => { + test.describe.configure({ timeout: 120_000 }); + let productId: string; + + test.beforeAll(async () => { + await ensureCustomerAddress(); + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor1 }); + const api = new ApiUtils(await request.newContext()); + [, productId] = await api.createProduct(payloads.createProduct(), payloads.vendorAuth); + }); + + test.afterAll(async () => { + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + }); + + test('customer can buy product using Stripe Connect on block checkout', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + log.success('Customer paid with Stripe Connect on block checkout — order received'); + } finally { + await page.close(); + await ctx.close(); + } + }); + + test('block checkout: declined card shows an error and creates no order', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.declined); + await stripe.placeBlockOrderExpectError(); + log.success('Block declined card surfaced an error and created no order'); + } finally { + await page.close(); + await ctx.close(); + } + }); +}); + +/** + * Marketplace split / money correctness (the highest-value risk area). Buys one + * product from each of two connected vendors in a single order, then verifies + * against the Stripe API that EXACTLY ONE transfer landed on each vendor's + * connected account for that charge — the no-double-transfer guarantee (R1) + + * the split fans out correctly. Requires REAL Stripe test connected accounts. + * + * Coverage note: Stripe webhooks can't reach localhost without the Stripe CLI, + * so only the SYNCHRONOUS transfer path runs here. The webhook-replay form of R1 + * (deliver payment_intent.succeeded twice) needs `stripe listen`/`events resend` + * and is logged as an uncovered gap, not silently skipped. + */ +test.describe.serial('Stripe Connect — multi-vendor split (real transfers)', () => { + test.describe.configure({ timeout: 120_000 }); + let product1: string; + let product2: string; + + test.beforeAll(async () => { + await ensureCustomerAddress(); + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor1 }); + await dbUtils.seedStripeConnectedVendor(VENDOR2_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor2 }); + const api = new ApiUtils(await request.newContext()); + [, product1] = await api.createProduct({ ...payloads.createProduct(), regular_price: '30' }, payloads.vendorAuth); + [, product2] = await api.createProduct({ ...payloads.createProduct(), regular_price: '20' }, payloads.vendor2Auth); + }); + + test.afterAll(async () => { + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + await dbUtils.removeStripeConnectedVendor(VENDOR2_ID); + }); + + test('two connected vendors in one cart → exactly one transfer to each (no double transfer)', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + test.skip( + !HAS_REAL_CONNECTED_ACCOUNTS, + 'needs REAL Stripe test connected accounts (STRIPE_VENDOR1_ACCT/STRIPE_VENDOR2_ACCT) — placeholder accounts cannot receive transfers', + ); + + // 1) One order containing a product from each connected vendor. + let orderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.addProductToCart(product2); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + orderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(orderId, 'captured the parent order id from the order-received URL').toBeTruthy(); + + // 2) Resolve the charge for the parent order. + const api = new ApiUtils(await request.newContext()); + const [, order] = await api.getSingleOrder(orderId as string, payloads.adminAuth); + const intentId = (order.meta_data ?? []).find( + (m: { key: string }) => m.key === '_stripe_intent_id' || m.key === 'dokan_stripe_intent_id', + )?.value; + expect(intentId, 'order should carry the Stripe PaymentIntent id').toBeTruthy(); + const chargeId = await stripeApi.getLatestChargeId(String(intentId)); + + // 3) EXACTLY ONE transfer per connected vendor for this charge (R1 + split fan-out). + await expect + .poll(async () => (await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1)).length, { + message: 'exactly one transfer to vendor1 for this charge (no double transfer)', + timeout: 25_000, + }) + .toBe(1); + await expect + .poll(async () => (await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor2)).length, { + message: 'exactly one transfer to vendor2 for this charge (no double transfer)', + timeout: 25_000, + }) + .toBe(1); + + // 4) Split sanity: each transfer > 0 and the sum doesn't exceed the captured charge. + const v1 = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1); + const v2 = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor2); + expect(v1[0].amount, 'vendor1 transfer amount > 0').toBeGreaterThan(0); + expect(v2[0].amount, 'vendor2 transfer amount > 0').toBeGreaterThan(0); + const pi = await stripeApi.getPaymentIntent(String(intentId)); + const captured = pi.amount_received ?? pi.amount; + expect(v1[0].amount + v2[0].amount, 'sum of vendor transfers must not exceed the charge').toBeLessThanOrEqual(captured); + + log.success(`Multi-vendor split verified: v1=${v1[0].amount}, v2=${v2[0].amount}, charge=${captured} (one transfer each)`); + }); + + test('webhook: replaying payment_intent.succeeded does not duplicate vendor transfers (R1/J2)', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + test.skip(!HAS_REAL_CONNECTED_ACCOUNTS, 'needs REAL Stripe test connected accounts'); + + // 1) Place a multi-vendor order (sync path → one transfer per vendor). + let orderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.addProductToCart(product2); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + orderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(orderId, 'captured the parent order id').toBeTruthy(); + + const intentId = await getStripeIntentIdForOrder(orderId as string); + const chargeId = await stripeApi.getLatestChargeId(intentId); + // Poll — the sync transfers may still be propagating to Stripe (eventual consistency). + await expect + .poll(async () => (await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1)).length, { + message: 'one transfer to vendor1 before replay', timeout: 25_000, + }) + .toBe(1); + await expect + .poll(async () => (await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor2)).length, { + message: 'one transfer to vendor2 before replay', timeout: 25_000, + }) + .toBe(1); + + // 2) Deliver + replay the payment_intent.succeeded webhook, UNSIGNED (R2 — the + // handler has no signature verification and re-fetches the event by id). + const event = await stripeApi.findPaymentIntentSucceededEvent(intentId); + expect(event, 'found the payment_intent.succeeded event').toBeTruthy(); + const status1 = await postWebhookEvent(event); + const status2 = await postWebhookEvent(event); + expect(status1, 'webhook accepts the unsigned event (R2 — no signature verification)').toBeLessThan(500); + expect(status2, 'webhook accepts the replayed unsigned event').toBeLessThan(500); + + // 3) Still EXACTLY one transfer per vendor — replay must not double-pay (R1). + await new Promise(resolve => setTimeout(resolve, 6_000)); + expect((await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1)).length, 'no duplicate transfer to vendor1 after webhook replay (R1)').toBe(1); + expect((await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor2)).length, 'no duplicate transfer to vendor2 after webhook replay (R1)').toBe(1); + log.success('Webhook replay did not duplicate transfers — R1/J2 idempotency holds'); + log.skip('The webhook-BEFORE-sync race (G6) needs Stripe CLI timing control — not covered here'); + }); +}); + +/** + * Refunds with transfer-reversal assertions (money correctness). A single-vendor + * order is fully refunded via Dokan's API refund flow; we verify against the + * Stripe API that BOTH the customer charge is refunded AND the vendor's transfer + * is reversed (funds pulled back). Single-vendor (no sub-orders) because Dokan + * blocks refunding a parent order that has sub-orders. + */ +test.describe.serial('Stripe Connect — refunds (transfer reversal)', () => { + test.describe.configure({ timeout: 120_000 }); + let product1: string; + let product2: string; + + test.beforeAll(async () => { + await ensureCustomerAddress(); + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor1 }); + await dbUtils.seedStripeConnectedVendor(VENDOR2_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor2 }); + const api = new ApiUtils(await request.newContext()); + [, product1] = await api.createProduct({ ...payloads.createProduct(), regular_price: '40' }, payloads.vendorAuth); + [, product2] = await api.createProduct({ ...payloads.createProduct(), regular_price: '25' }, payloads.vendor2Auth); + }); + + test.afterAll(async () => { + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + await dbUtils.removeStripeConnectedVendor(VENDOR2_ID); + }); + + test('full refund reverses the vendor transfer and refunds the customer', { tag: ['@pro', '@admin'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + test.skip( + !HAS_REAL_CONNECTED_ACCOUNTS, + 'transfer reversal needs a REAL Stripe test connected account (a placeholder has no transfer to reverse)', + ); + + // 1) Place a single-vendor order so there are no sub-orders to block the refund. + let orderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + orderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(orderId, 'captured the order id from the order-received URL').toBeTruthy(); + + // 2) Resolve the charge + the vendor transfer that needs reversing. + const api = new ApiUtils(await request.newContext()); + const [, order] = await api.getSingleOrder(orderId as string, payloads.adminAuth); + const intentId = (order.meta_data ?? []).find( + (m: { key: string }) => m.key === '_stripe_intent_id' || m.key === 'dokan_stripe_intent_id', + )?.value; + expect(intentId, 'order should carry the Stripe PaymentIntent id').toBeTruthy(); + const chargeId = await stripeApi.getLatestChargeId(String(intentId)); + const transfersBefore = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1); + expect(transfersBefore.length, 'one vendor transfer exists before the refund').toBe(1); + const transfer = transfersBefore[0]; + const chargeAmount = (await stripeApi.getCharge(chargeId)).amount; + + // 3) Full API refund → Stripe customer refund + vendor transfer reversal. + await dokanApiRefund(orderId as string); + + // 4) Customer fully refunded on the platform charge. + await expect + .poll(async () => (await stripeApi.getCharge(chargeId)).amount_refunded, { + message: 'the customer charge should be fully refunded', + timeout: 30_000, + }) + .toBe(chargeAmount); + + // 5) The vendor transfer is reversed (funds pulled back from the vendor). + await expect + .poll(async () => (await stripeApi.getTransfer(transfer.id)).amount_reversed, { + message: 'the vendor transfer should be reversed', + timeout: 30_000, + }) + .toBeGreaterThan(0); + const reversed = (await stripeApi.getTransfer(transfer.id)).amount_reversed; + expect(reversed, 'reversal cannot exceed the original transfer').toBeLessThanOrEqual(transfer.amount); + + log.success(`Refund verified: charge ${chargeId} fully refunded + transfer ${transfer.id} reversed (${reversed}/${transfer.amount})`); + }); + + test('refund of an order missing the intent meta does not reverse the transfer', { tag: ['@pro', '@admin'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + test.skip(!HAS_REAL_CONNECTED_ACCOUNTS, 'needs a REAL Stripe test connected account (a placeholder has no transfer)'); + + // 1) Place a single-vendor order (sync path writes dokan_stripe_intent_id + creates a transfer). + let orderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + orderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(orderId, 'captured the order id').toBeTruthy(); + + const apiCtx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + try { + // 2) Capture the charge + the vendor transfer that SHOULD be reversed by a refund. + const orderRes = await apiCtx.get(`${SERVER_URL}/wc/v3/orders/${orderId}?_fields=id,meta_data`); + const orderBody = (await orderRes.json()) as { meta_data: Array<{ key: string; value: string }> }; + const intentId = orderBody.meta_data.find(m => m.key === '_stripe_intent_id' || m.key === 'dokan_stripe_intent_id')?.value; + expect(intentId, 'order has a PaymentIntent id').toBeTruthy(); + const chargeId = await stripeApi.getLatestChargeId(String(intentId)); + const transfers = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1); + expect(transfers.length, 'a vendor transfer exists').toBe(1); + const transferId = transfers[0].id; + + // 3) Reproduce the R8 condition: a redirect/webhook-settled order never gets + // dokan_stripe_intent_id written (sync-path only). Clear it. + await apiCtx.put(`${SERVER_URL}/wc/v3/orders/${orderId}`, { + data: { meta_data: [{ key: 'dokan_stripe_intent_id', value: '' }] }, + }); + + // 4) Attempt a full API refund. process_3ds_refund early-returns when the + // intent meta is empty (Refund.php:71) — so nothing happens on Stripe. + await dokanApiRefund(orderId as string); + await new Promise(resolve => setTimeout(resolve, 6_000)); // give any processing a chance + + // 5) R8 impact: the customer is NOT refunded and the vendor transfer is NOT + // reversed — the order is silently un-refundable, vendor keeps the funds. + const charge = await stripeApi.getCharge(chargeId); + const transfer = await stripeApi.getTransfer(transferId); + expect(charge.amount_refunded, 'R8: customer NOT refunded (intent meta missing)').toBe(0); + expect(transfer.amount_reversed, 'R8: vendor transfer NOT reversed (intent meta missing)').toBe(0); + log.warn( + 'Order missing dokan_stripe_intent_id', + 'refund reversed nothing and refunded nothing — behaviour tracked in internal QA notes', + ); + } finally { + await apiCtx.dispose(); + } + }); + + test('partial refund reverses a proportional part of the vendor transfer', { tag: ['@pro', '@admin'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + test.skip(!HAS_REAL_CONNECTED_ACCOUNTS, 'needs a REAL Stripe test connected account'); + + let orderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + orderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(orderId, 'captured the order id').toBeTruthy(); + + const chargeId = await getStripeChargeIdForOrder(orderId as string); + const before = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1); + expect(before.length, 'one vendor transfer before the refund').toBe(1); + const transfer = before[0]; + + await dokanApiRefund(orderId as string, 10); // partial: refund $10 + + await expect + .poll(async () => (await stripeApi.getCharge(chargeId)).amount_refunded, { + message: 'customer should be partially refunded ($10 = 1000 cents)', + timeout: 30_000, + }) + .toBe(1000); + await expect + .poll(async () => (await stripeApi.getTransfer(transfer.id)).amount_reversed, { + message: 'vendor transfer should be partially reversed', + timeout: 30_000, + }) + .toBeGreaterThan(0); + const reversed = (await stripeApi.getTransfer(transfer.id)).amount_reversed; + expect(reversed, 'partial reversal must be less than the full transfer').toBeLessThan(transfer.amount); + log.success(`Partial refund verified: customer refunded 1000¢, transfer partially reversed (${reversed}/${transfer.amount})`); + }); + + test('multi-vendor: refunding one sub-order reverses only that vendor transfer', { tag: ['@pro', '@admin'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing'); + test.skip(!HAS_REAL_CONNECTED_ACCOUNTS, 'needs REAL Stripe test connected accounts'); + // KNOWN ISSUE: refunding a multi-vendor sub-order currently errors out (tracked in + // internal QA notes). Marked expected-fail so it documents the gap and flips to a + // real pass once fixed. + test.fail(true, 'multi-vendor sub-order refund currently errors — tracked in internal QA notes'); + + // 1) One order spanning both connected vendors → parent + 2 sub-orders, 1 transfer each. + let parentOrderId: string | undefined; + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(product1); + await stripe.addProductToCart(product2); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + await stripe.fillCardDetails(STRIPE_CARDS.success); + await stripe.placeBlockOrderExpectReceived(); + parentOrderId = page.url().match(/order-received\/(\d+)/)?.[1]; + } finally { + await page.close(); + await ctx.close(); + } + expect(parentOrderId, 'captured the parent order id').toBeTruthy(); + + const chargeId = await getStripeChargeIdForOrder(parentOrderId as string); + const v1Before = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor1); + const v2Before = await stripeApi.transfersForChargeToVendor(chargeId, STRIPE_CONNECTED_ACCOUNTS.vendor2); + expect(v1Before.length, 'vendor1 transfer exists').toBe(1); + expect(v2Before.length, 'vendor2 transfer exists').toBe(1); + + // 2) Find vendor1's sub-order (the one carrying vendor1's transfer id). + const apiCtx = await request.newContext({ extraHTTPHeaders: payloads.adminAuth as Record }); + let v1SubOrderId: number | undefined; + try { + const subRes = await apiCtx.get(`${SERVER_URL}/wc/v3/orders?parent=${parentOrderId}&per_page=20&_fields=id,meta_data`); + const subs = (await subRes.json()) as Array<{ id: number; meta_data: Array<{ key: string; value: string }> }>; + v1SubOrderId = subs.find(s => s.meta_data?.some(m => m.key === '_dokan_stripe_transfer_id' && m.value === v1Before[0].id))?.id; + } finally { + await apiCtx.dispose(); + } + expect(v1SubOrderId, 'found vendor1 sub-order').toBeTruthy(); + + // 3) Refund vendor1's sub-order in full. + await dokanApiRefund(v1SubOrderId as number); + + // 4) Only vendor1's transfer is reversed; vendor2's is untouched. + await expect + .poll(async () => (await stripeApi.getTransfer(v1Before[0].id)).amount_reversed, { + message: 'vendor1 transfer should be reversed', timeout: 30_000, + }) + .toBeGreaterThan(0); + const v2 = await stripeApi.getTransfer(v2Before[0].id); + expect(v2.amount_reversed, "vendor2 transfer must be untouched").toBe(0); + log.success('Multi-vendor refund: vendor1 sub-order reversed, vendor2 transfer untouched'); + }); +}); + +/** + * 3DS / SCA. The new gateway always uses PaymentIntents, so `4000…3155` forces an + * in-page SCA challenge; completing it must settle the order. Single-vendor order. + */ +test.describe.serial('Stripe Connect — 3DS / SCA', () => { + test.describe.configure({ timeout: 120_000 }); + let productId: string; + + test.beforeAll(async () => { + await ensureCustomerAddress(); + await dbUtils.seedStripeConnectedVendor(VENDOR_ID, { accountId: STRIPE_CONNECTED_ACCOUNTS.vendor1 }); + const api = new ApiUtils(await request.newContext()); + [, productId] = await api.createProduct(payloads.createProduct(), payloads.vendorAuth); + }); + + test.afterAll(async () => { + await dbUtils.removeStripeConnectedVendor(VENDOR_ID); + }); + + test('classic checkout: 3DS card completes after the SCA challenge', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing — set TEST_*_STRIPE_CONNECT in tests/pw/.env'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoClassicCheckout(); + await stripe.fillBillingClassic(); + await stripe.selectClassicGateway(); + const baseline = await getLatestStripeOrderId(); // pin: only a NEWER order (this test's) counts + await stripe.fillCardDetails(STRIPE_CARDS.threeDS); + await page.locator(stripe.checkout.placeOrderClassic).click(); + await stripe.complete3DSChallenge(); + await assertStripeOrderSettledSince(baseline); + log.success('Classic 3DS order settled after completing the SCA challenge'); + } finally { + await page.close(); + await ctx.close(); + } + }); + + test('block checkout: 3DS card completes after the SCA challenge', { tag: ['@pro', '@customer'] }, async ({ browser }) => { + test.skip(!hasCredentials, 'Stripe Connect test keys missing'); + const ctx = await browser.newContext({ storageState: customerAuth }); + const page = await ctx.newPage(); + try { + const stripe = new StripeConnectPage(page); + await dbUtils.clearCustomerCart(CUSTOMER_ID); + await stripe.addProductToCart(productId); + await stripe.gotoBlockCheckout(); + await stripe.selectBlockGateway(); + const baseline = await getLatestStripeOrderId(); // pin: only a NEWER order (this test's) counts + await stripe.fillCardDetails(STRIPE_CARDS.threeDS); + await page.locator(stripe.blockSelectors.placeOrder).click(); + await stripe.complete3DSChallenge(); + await assertStripeOrderSettledSince(baseline); + log.success('Block 3DS order settled after completing the SCA challenge'); + } finally { + await page.close(); + await ctx.close(); + } + }); +}); diff --git a/tests/pw/tests/e2e/stripe-connect/stripeConnectPage.ts b/tests/pw/tests/e2e/stripe-connect/stripeConnectPage.ts new file mode 100644 index 0000000000..8962b8d55f --- /dev/null +++ b/tests/pw/tests/e2e/stripe-connect/stripeConnectPage.ts @@ -0,0 +1,663 @@ +import { Page, expect } from '@playwright/test'; +import { toPath, closeAnnouncementModal } from '@utils/helpers'; + +// The suite's strict tsconfig doesn't pull in `@types/node`, so `process` would +// otherwise be flagged as undefined. Declare it locally (same pattern as +// `abuse-reports/abuseReportsPage.ts`) to keep the file type-clean. +declare const process: { env: Record }; + +/** + * Stripe test cards (test mode only). Expiry must be any future date, CVC any + * 3 digits, ZIP any value. Mirrors the cards listed in the manual-test-plan §2.3. + */ +export const STRIPE_CARDS = { + success: '4242 4242 4242 4242', + threeDS: '4000 0025 0000 3155', // SCA required (on-session) + threeDSRenewal: '4000 0027 6000 3184', // SCA required (off-session/renewal) + declined: '4000 0000 0000 0002', // card_declined + insufficientFunds: '4000 0000 0000 9995', + expired: '4000 0000 0000 0069', + incorrectCvc: '4000 0000 0000 0127', + exp: '12 / 34', + cvc: '123', + zip: '10003', +} as const; + +/** + * Stripe Connect test credentials. Sourced from env vars whose names match the + * dokan-pro CI workflow (`e2e_api_tests.yml`) so CI injects them automatically; + * locally they live in `tests/pw/.env` (gitignored). + * + * `clientId` is the test Connect Client ID (`ca_…`) for the SAME account as the + * keys. The gateway is only "ready" (and the vendor connect button / withdraw + * method only render) when enabled + secret key + client id are all set + * (dokan-pro `RegisterWithdrawMethods::admin_notices`). When `clientId` is empty + * the gateway stays "almost ready" and the vendor-connect flow must be skipped. + */ +export const STRIPE_CONNECT_KEYS = { + publishable: process.env.TEST_PUBLISH_KEY_STRIPE_CONNECT || '', + secret: process.env.TEST_SECRET_KEY_STRIPE_CONNECT || '', + clientId: process.env.TEST_CLIENT_ID_STRIPE_CONNECT || '', +} as const; + +/** + * Connected-account ids seeded onto the vendors (dokan_connected_vendor_id). + * Read from .env so real Stripe test connected accounts can be dropped in without + * touching code; falls back to placeholders (enough for the card charge, which is + * on the platform account — real transfers need real accounts). + */ +export const STRIPE_CONNECTED_ACCOUNTS = { + vendor1: process.env.STRIPE_VENDOR1_ACCT || 'acct_seeded_demo_vendor1', + vendor2: process.env.STRIPE_VENDOR2_ACCT || 'acct_seeded_demo_vendor2', +} as const; + +/** True when both connected-account ids look like REAL Stripe accounts (not placeholders). */ +export const HAS_REAL_CONNECTED_ACCOUNTS = + /^acct_[A-Za-z0-9]+$/.test(STRIPE_CONNECTED_ACCOUNTS.vendor1) && + !STRIPE_CONNECTED_ACCOUNTS.vendor1.includes('seeded_demo') && + /^acct_[A-Za-z0-9]+$/.test(STRIPE_CONNECTED_ACCOUNTS.vendor2) && + !STRIPE_CONNECTED_ACCOUNTS.vendor2.includes('seeded_demo'); + +export const ALMOST_READY_NOTICE = 'Stripe Connect module is almost ready!'; + +export interface GatewayConfig { + publishable: string; + secret: string; + clientId?: string; + enable?: boolean; + testmode?: boolean; + savedCards?: boolean; + allowNonConnected?: boolean; +} + +export class StripeConnectPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + // Vendor dashboard flows are blocked by the Dokan Pro 5.0 announcement + // modal unless dismissed; harmless no-op on admin pages. + void closeAnnouncementModal(page); + } + + // ============================================ + // SELECTORS + // ============================================ + + admin = { + // Modules page (legacy Vue UI; toggle is `input.toogle-checkbox` inside + // `label.switch`, scoped per card via the bulk-select `input[value=]`). + modulesUrl: toPath('wp-admin/admin.php?page=dokan#/modules'), + moduleCard: (slug: string) => `.module-card:has(input[value="${slug}"])`, + moduleToggleInput: (slug: string) => `.module-card:has(input[value="${slug}"]) input.toogle-checkbox`, + moduleToggleSwitch: (slug: string) => `.module-card:has(input[value="${slug}"]) label.switch`, + + // WC payment gateway settings (field ids = woocommerce__). + gatewayUrl: toPath('wp-admin/admin.php?page=wc-settings&tab=checkout§ion=dokan-stripe-connect'), + enabled: '#woocommerce_dokan-stripe-connect_enabled', + testmode: '#woocommerce_dokan-stripe-connect_testmode', + testPublishableKey: '#woocommerce_dokan-stripe-connect_test_publishable_key', + testSecretKey: '#woocommerce_dokan-stripe-connect_test_secret_key', + testClientId: '#woocommerce_dokan-stripe-connect_test_client_id', + savedCards: '#woocommerce_dokan-stripe-connect_saved_cards', + allowNonConnectedSellers: '#woocommerce_dokan-stripe-connect_allow_non_connected_sellers', + enable3dSecure: '#woocommerce_dokan-stripe-connect_enable_3d_secure', // rendered disabled (SCA always on) + saveButton: 'button.woocommerce-save-button[name="save"]', + + // Dokan settings (where gateway readiness surfaces + the withdraw method + // is enabled for vendors). Legacy Vue UI; the withdraw-methods control is + // a `.multicheck_fields` group of `label.switch` toggles keyed by method + // value (the gateway id `dokan-stripe-connect`). Save = #submit (admin-ajax + // action=dokan_save_settings). + dokanSettingsUrl: toPath('wp-admin/admin.php?page=dokan#/settings'), + withdrawOptionsTab: 'div.nav-tab:has(div.nav-title:text-is("Withdraw Options"))', + almostReadyNotice: `text=${ALMOST_READY_NOTICE}`, + dokanSettingsSaveButton: '#submit', + withdrawMethodToggleInput: (value: string) => `.multicheck_fields input.toogle-checkbox[value="${value}"]`, + withdrawMethodToggleSwitch: (value: string) => `.multicheck_fields label.switch:has(input[value="${value}"])`, + }; + + static readonly WITHDRAW_METHOD = 'dokan-stripe-connect'; + + vendor = { + paymentSettingsUrl: toPath('dashboard/settings/payment'), + // The connect/disconnect UI lives on the Stripe Connect MANAGE sub-route. + // Real path: payment page → hover "Add Payment Method" (a CSS-hover + // dropdown) → click "Direct to Stripe Connect". The link's href IS the + // manage route, so a direct nav is also valid (used for state probes). + stripeManageUrl: toPath('dashboard/settings/payment-manage-dokan-stripe-connect'), + addPaymentMethodTrigger: '#toggle-vendor-payment-method-drop-down', + addStripeConnectLink: 'a[href*="payment-manage-dokan-stripe-connect"]', + container: '.dokan-stripe-connect-container', + connectButton: 'a.dokan-stripe-connect-link', + disconnectButton: '.dokan-stripe-connect-container a.dokan-btn-danger', + connectedAlert: '.dokan-stripe-connect-container .dokan-alert-success', + notConnectedAlert: '.dokan-stripe-connect-container .dokan-alert-warning', + }; + + static readonly STRIPE_OAUTH_URL = 'https://connect.stripe.com/oauth/authorize'; + + // Checkout selectors — verified live + against the PR source. + checkout = { + // Classic = a [woocommerce_checkout] shortcode page (the default /checkout/ + // on this site is the WC Checkout BLOCK). ensureClassicCheckoutPage() creates it. + classicUrl: toPath('classic-checkout'), + blockUrl: toPath('checkout'), + addToCart: (productId: string | number) => toPath(`?add-to-cart=${productId}`), + gatewayRadio: 'input[name="payment_method"][value="dokan-stripe-connect"]', + classicMount: '#dokan-stripe-connect-payment-element', + classicFieldset: '#wc-dokan-stripe-connect-cc-form', + blockMount: '.dokan-stripe-connect-payment-element', + savedTokenRadios: 'input[name="wc-dokan-stripe-connect-payment-token"]', + saveCardCheckbox: '#wc-dokan-stripe-connect-new-payment-method', + expressWrap: '.dokan-stripe-pe-express-wrap', + expressMount: '#dokan-stripe-connect-express-checkout', + error: 'ul.dokan-stripe-pe-error, .woocommerce-error, .wc-block-components-notice-banner.is-error', + placeOrderClassic: '#place_order', + // Block place-order button (text node lives in a child span). + placeOrderBlock: '.wc-block-components-checkout-place-order-button', + orderReceived: '.woocommerce-order-received, .woocommerce-thankyou-order-received', + // Classic billing fields. + billing: { + firstName: '#billing_first_name', + lastName: '#billing_last_name', + email: '#billing_email', + phone: '#billing_phone', + address1: '#billing_address_1', + city: '#billing_city', + postcode: '#billing_postcode', + country: '#billing_country', + state: '#billing_state', + }, + }; + + static readonly BILLING = { + firstName: 'customer1', + lastName: 'c1', + email: 'customer1@email.com', + phone: '(555) 555-5555', + address1: 'abc street', + city: 'New York', + postcode: '10003', + country: 'US', + state: 'NY', + }; + + // ============================================ + // MODULES (admin) + // ============================================ + + async gotoModules(): Promise { + await this.page.goto(this.admin.modulesUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator('.module-card').first().waitFor({ state: 'visible', timeout: 30_000 }); + } + + async isModuleEnabled(slug: string): Promise { + return this.page.locator(this.admin.moduleToggleInput(slug)).isChecked(); + } + + /** + * Idempotently set a module's active state via the modules-page UI toggle. + * Waits on the real REST activate/deactivate response, then confirms the + * toggle reflects the desired state. + */ + async setModuleEnabled(slug: string, enabled: boolean): Promise { + const toggle = this.page.locator(this.admin.moduleToggleInput(slug)); + if ((await toggle.isChecked()) === enabled) { + return; // already in desired state + } + const endpoint = enabled ? 'activate' : 'deactivate'; + await Promise.all([ + this.page.waitForResponse( + res => res.url().includes(`/modules/${endpoint}`) && res.ok(), + { timeout: 30_000 }, + ), + this.page.locator(this.admin.moduleToggleSwitch(slug)).click(), + ]); + await expect(toggle, `module "${slug}" should be ${enabled ? 'enabled' : 'disabled'}`).toBeChecked({ + checked: enabled, + timeout: 15_000, + }); + } + + // ============================================ + // GATEWAY SETTINGS (admin) + // ============================================ + + async gotoGatewaySettings(): Promise { + await this.page.goto(this.admin.gatewayUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.admin.enabled).waitFor({ state: 'visible', timeout: 30_000 }); + } + + /** + * Fill + save the Stripe Connect gateway settings. WooCommerce reloads the + * settings page on save (full form POST), so we wait for that and confirm + * the form is back before returning. + */ + async configureGateway(config: GatewayConfig): Promise { + const { + publishable, + secret, + clientId = '', + enable = true, + testmode = true, + savedCards = true, + allowNonConnected = false, + } = config; + + await this.gotoGatewaySettings(); + + await this.page.locator(this.admin.enabled).setChecked(enable); + await this.page.locator(this.admin.testmode).setChecked(testmode); + await this.page.locator(this.admin.testPublishableKey).fill(publishable); + await this.page.locator(this.admin.testSecretKey).fill(secret); + if (clientId) { + await this.page.locator(this.admin.testClientId).fill(clientId); + } + await this.page.locator(this.admin.savedCards).setChecked(savedCards); + await this.page.locator(this.admin.allowNonConnectedSellers).setChecked(allowNonConnected); + + await this.page.locator(this.admin.saveButton).click(); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.admin.enabled).waitFor({ state: 'visible', timeout: 30_000 }); + } + + /** Re-read the gateway settings page and assert the key fields persisted. */ + async assertGatewayConfigured(expected: { enabled?: boolean; testmode?: boolean } = {}): Promise { + const { enabled = true, testmode = true } = expected; + await this.gotoGatewaySettings(); + await expect(this.page.locator(this.admin.enabled), 'gateway enabled persisted').toBeChecked({ checked: enabled }); + await expect(this.page.locator(this.admin.testmode), 'test mode persisted').toBeChecked({ checked: testmode }); + await expect(this.page.locator(this.admin.testPublishableKey), 'test publishable key persisted').not.toHaveValue(''); + await expect(this.page.locator(this.admin.testSecretKey), 'test secret key persisted').not.toHaveValue(''); + } + + /** The "3D Secure / SCA" field is rendered disabled (SCA is unconditional). */ + async assert3dSecureFieldDisabled(): Promise { + await this.gotoGatewaySettings(); + await expect(this.page.locator(this.admin.enable3dSecure), '3DS field is disabled (SCA always on)').toBeDisabled(); + } + + // ============================================ + // GATEWAY READINESS (admin → Dokan settings) + // ============================================ + + async gotoDokanSettings(): Promise { + await this.page.goto(this.admin.dokanSettingsUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator('.nav-tab').first().waitFor({ state: 'visible', timeout: 30_000 }); + } + + /** + * The gateway is "ready" only when enabled + secret + client id are all set. + * Until then dokan-pro renders the "almost ready" admin notice and the Stripe + * Connect withdraw method / vendor connect button never register. + * + * The notice is conditionally *rendered* (present in the DOM only while + * NOT ready), but it can sit inside a collapsed notices panel — so its + * visibility is unreliable. Assert on DOM attachment, not visibility. + */ + async assertGatewayReady(): Promise { + await this.gotoDokanSettings(); + await expect( + this.page.locator(this.admin.almostReadyNotice).first(), + 'gateway should be ready — the "almost ready" notice must be gone', + ).not.toBeAttached({ timeout: 20_000 }); + } + + async assertGatewayAlmostReady(): Promise { + await this.gotoDokanSettings(); + await expect( + this.page.locator(this.admin.almostReadyNotice).first(), + 'gateway should report "almost ready" without a client id', + ).toBeAttached({ timeout: 20_000 }); + } + + // ============================================ + // WITHDRAW METHOD (admin → Dokan settings → Withdraw Options) + // ============================================ + + async gotoWithdrawOptions(): Promise { + await this.gotoDokanSettings(); + await this.page.locator(this.admin.withdrawOptionsTab).click(); + // The Stripe Connect toggle only renders here once the gateway is ready. + await this.page + .locator(this.admin.withdrawMethodToggleInput(StripeConnectPage.WITHDRAW_METHOD)) + .waitFor({ state: 'attached', timeout: 20_000 }); + } + + /** + * Enable (or disable) Stripe Connect as a vendor withdraw method and save. + * This is what surfaces the connect button in the vendor dashboard. Idempotent + * — skips the toggle+save when already in the desired state. Requires the + * gateway to be ready (client id set), or the toggle won't exist. + */ + async setStripeWithdrawMethodEnabled(enabled = true): Promise { + const value = StripeConnectPage.WITHDRAW_METHOD; + await this.gotoWithdrawOptions(); + const toggle = this.page.locator(this.admin.withdrawMethodToggleInput(value)); + if ((await toggle.isChecked()) === enabled) { + return; // already persisted in the desired state + } + await this.page.locator(this.admin.withdrawMethodToggleSwitch(value)).click(); + await Promise.all([ + this.page.waitForResponse( + res => + res.url().includes('admin-ajax.php') && + res.request().method() === 'POST' && + (res.request().postData() ?? '').includes('action=dokan_save_settings'), + { timeout: 30_000 }, + ), + this.page.locator(this.admin.dokanSettingsSaveButton).click(), + ]); + // confirm it persisted + await this.gotoWithdrawOptions(); + await expect( + this.page.locator(this.admin.withdrawMethodToggleInput(value)), + 'Stripe Connect withdraw method should persist as enabled', + ).toBeChecked({ checked: enabled, timeout: 15_000 }); + } + + // ============================================ + // VENDOR CONNECT (vendor dashboard) + // ============================================ + + /** Open the Stripe Connect manage page directly (robust, state-independent). */ + async gotoVendorStripeManage(): Promise { + await this.page.goto(this.vendor.stripeManageUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.vendor.container).waitFor({ state: 'visible', timeout: 20_000 }); + } + + /** + * Open the Stripe Connect manage page the way a vendor does: payment settings + * → hover "Add Payment Method" → click "Direct to Stripe Connect". + */ + async gotoVendorStripeViaPaymentMenu(): Promise { + await this.page.goto(this.vendor.paymentSettingsUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.vendor.addPaymentMethodTrigger).hover(); + await this.page.locator(this.vendor.addStripeConnectLink).click(); + await this.page.locator(this.vendor.container).waitFor({ state: 'visible', timeout: 20_000 }); + } + + async isVendorConnected(): Promise { + await this.gotoVendorStripeManage(); + return this.page.locator(this.vendor.disconnectButton).isVisible().catch(() => false); + } + + /** + * Assert the connect button renders and is wired to Stripe OAuth (its href is + * a `connect.stripe.com/oauth/authorize` URL built from the client id), + * navigating the real vendor path (Payments → Add Payment Method → Direct to + * Stripe Connect). Verifies "vendor can add Stripe Connect payment method". + */ + async assertConnectButtonVisible(): Promise { + await this.gotoVendorStripeViaPaymentMenu(); + const connect = this.page.locator(this.vendor.connectButton); + await expect(connect, 'vendor Stripe connect button should render').toBeVisible({ timeout: 20_000 }); + await expect(connect, 'connect button should point to Stripe OAuth').toHaveAttribute( + 'href', + new RegExp('^' + StripeConnectPage.STRIPE_OAUTH_URL.replace(/[.]/g, '\\.')), + ); + } + + /** + * Click "Connect with Stripe" and assert the vendor is taken to Stripe's + * hosted OAuth page. The OAuth round-trip itself is Stripe-hosted and cannot + * be automated further — completing it is manual, or the connected state is + * seeded via user meta for downstream money tests. (Hits external Stripe.) + */ + async clickConnectExpectStripeRedirect(): Promise { + await this.gotoVendorStripeManage(); + await Promise.all([ + this.page.waitForURL(/connect\.stripe\.com/, { timeout: 30_000 }), + this.page.locator(this.vendor.connectButton).click(), + ]); + } + + /** Assert the vendor sees the CONNECTED state (Disconnect + Merchant ID). */ + async assertVendorConnectedUI(expectedAccountId?: string): Promise { + await this.gotoVendorStripeManage(); + await expect(this.page.locator(this.vendor.disconnectButton), 'connected vendor should see Disconnect').toBeVisible({ + timeout: 20_000, + }); + if (expectedAccountId) { + await expect( + this.page.locator(this.vendor.connectedAlert), + 'connected alert should show the Merchant ID', + ).toContainText(expectedAccountId); + } + } + + /** Assert the vendor sees the NOT-CONNECTED state (Connect button). */ + async assertVendorNotConnectedUI(): Promise { + await this.gotoVendorStripeManage(); + await expect(this.page.locator(this.vendor.connectButton), 'unconnected vendor should see Connect').toBeVisible({ + timeout: 20_000, + }); + } + + // ============================================ + // CARD ENTRY (Stripe Payment Element iframe) — for upcoming checkout specs + // ============================================ + + /** + * Fill the Stripe Payment Element card fields. The PE iframe `name` is + * randomized (`__privateStripeFrame*`); discover it by the frame that + * actually contains a card-number input. Field ids are stable + * (`#payment-numberInput` / `#payment-expiryInput` / `#payment-cvcInput`). + */ + private static readonly PE_NUMBER = '#payment-numberInput, input[name="number"]'; + private static readonly PE_EXPIRY = '#payment-expiryInput, input[name="expiry"]'; + private static readonly PE_CVC = '#payment-cvcInput, input[name="cvc"]'; + private static readonly PE_ZIP = '#payment-postalCodeInput, input[name="postalCode"]'; + + /** Find the (re-mountable) PE iframe that currently holds the card-number field. */ + private async findStripePeFrame() { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + for (const frame of this.page.frames()) { + if (!frame.name().includes('__privateStripeFrame')) { + continue; + } + if (await frame.locator(StripeConnectPage.PE_NUMBER).count().catch(() => 0)) { + return frame; + } + } + await this.page.waitForTimeout(400); + } + throw new Error('Stripe Payment Element card iframe not found'); + } + + /** + * Fill the Stripe Payment Element. The PE iframe is cross-origin and can + * RE-MOUNT (WC `updated_checkout` re-renders the payment box), which detaches + * the frame mid-entry. So fill all three fields against a freshly-resolved + * frame and verify the card number stuck — retry the WHOLE entry if the PE + * re-mounted (a per-field retry would leave the number blank in a new frame). + */ + async fillCardDetails(card: string = STRIPE_CARDS.success): Promise { + const deadline = Date.now() + 45_000; + let lastErr: unknown; + while (Date.now() < deadline) { + try { + const frame = await this.findStripePeFrame(); + await frame.locator(StripeConnectPage.PE_NUMBER).first().fill(card, { timeout: 8_000 }); + await frame.locator(StripeConnectPage.PE_EXPIRY).first().fill(STRIPE_CARDS.exp, { timeout: 8_000 }); + await frame.locator(StripeConnectPage.PE_CVC).first().fill(STRIPE_CARDS.cvc, { timeout: 8_000 }); + const zip = frame.locator(StripeConnectPage.PE_ZIP); + if (await zip.count().catch(() => 0)) { + await zip.first().fill(STRIPE_CARDS.zip).catch(() => undefined); + } + // Confirm the number stuck (PE didn't re-mount empty mid-entry). + const val = (await frame.locator(StripeConnectPage.PE_NUMBER).first().inputValue().catch(() => '')).replace(/\s/g, ''); + if (val.length >= 12) { + return; + } + lastErr = new Error('card number did not persist — PE re-mounted during entry'); + } catch (err) { + lastErr = err; // detached/closed frame → re-resolve and retry the whole entry + } + await this.page.waitForTimeout(700); + } + throw lastErr ?? new Error('failed to fill Stripe Payment Element'); + } + + // ============================================ + // CHECKOUT (customer) + // ============================================ + + async addProductToCart(productId: string | number): Promise { + await this.page.goto(this.checkout.addToCart(productId)); + await this.page.waitForLoadState('domcontentloaded'); + } + + async gotoClassicCheckout(): Promise { + await this.page.goto(this.checkout.classicUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.checkout.placeOrderClassic).waitFor({ state: 'visible', timeout: 30_000 }); + } + + /** Fill the classic checkout billing fields (overwrites any pre-filled values). */ + async fillBillingClassic(billing = StripeConnectPage.BILLING): Promise { + const f = this.checkout.billing; + // Country first — WC re-renders the state field when it changes. + await this.page.selectOption(f.country, billing.country).catch(() => undefined); + await this.page.fill(f.firstName, billing.firstName); + await this.page.fill(f.lastName, billing.lastName); + await this.page.fill(f.address1, billing.address1); + await this.page.fill(f.city, billing.city); + await this.page.fill(f.postcode, billing.postcode); + await this.page.selectOption(f.state, billing.state).catch(async () => { + await this.page.fill(f.state, billing.state).catch(() => undefined); + }); + if (await this.page.locator(f.email).isVisible().catch(() => false)) { + await this.page.fill(f.email, billing.email); + } + if (await this.page.locator(f.phone).isVisible().catch(() => false)) { + await this.page.fill(f.phone, billing.phone); + } + } + + /** + * WC blocks the classic checkout form with a `.blockOverlay` while an + * `update_checkout` AJAX runs. Selecting a gateway / changing billing triggers + * one, which RE-RENDERS the payment box (re-mounting the PE iframe). Wait it + * out so the Payment Element is stable before card entry — otherwise the + * iframe detaches mid-fill (the observed flake). + */ + async waitForCheckoutSettled(): Promise { + await this.page + .waitForFunction(() => !document.querySelector('.blockOverlay'), { timeout: 20_000 }) + .catch(() => undefined); + } + + /** Select the Stripe Connect gateway and wait for the Payment Element to mount + settle. */ + async selectClassicGateway(): Promise { + await this.waitForCheckoutSettled(); // settle any update from billing entry + // The radio is overlaid by a styled
  • that intercepts pointer events, so + // click its LABEL (what WC listens to). Confirm selection by the PE mounting + // (it only renders when Stripe Connect is the chosen method) — avoids the + // radio-state race from the update_checkout re-render. + const label = this.page.locator('label[for="payment_method_dokan-stripe-connect"]'); + await label.scrollIntoViewIfNeeded().catch(() => undefined); + await label.click(); + await this.waitForCheckoutSettled(); // settle the update_checkout from the gateway change + await this.page.locator(this.checkout.classicMount).waitFor({ state: 'visible', timeout: 30_000 }); + await this.waitForCheckoutSettled(); // ensure the PE box finished (re)rendering + } + + async placeClassicOrderExpectReceived(): Promise { + await this.page.locator(this.checkout.placeOrderClassic).click(); + await this.page.waitForURL('**/order-received/**', { timeout: 60_000 }); + } + + async placeClassicOrderExpectError(): Promise { + await this.page.locator(this.checkout.placeOrderClassic).click(); + await expect( + this.page.locator(this.checkout.error).first(), + 'declined card should surface an inline error', + ).toBeVisible({ timeout: 40_000 }); + await expect(this.page, 'declined card must not reach order-received').not.toHaveURL(/order-received/); + } + + // ---- Block checkout (WC Checkout block; the site default /checkout/) ---- + + blockSelectors = { + gatewayRadio: '#radio-control-wc-payment-method-options-dokan-stripe-connect', + placeOrder: '.wc-block-components-checkout-place-order-button', + error: '.wc-block-components-notice-banner.is-error, .woocommerce-error', + }; + + async gotoBlockCheckout(): Promise { + await this.page.goto(this.checkout.blockUrl); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator(this.blockSelectors.placeOrder).waitFor({ state: 'visible', timeout: 30_000 }); + } + + /** Select the Stripe Connect block payment method and wait for the PE to mount. */ + async selectBlockGateway(): Promise { + const radio = this.page.locator(this.blockSelectors.gatewayRadio); + await radio.waitFor({ state: 'visible', timeout: 30_000 }); + await radio.click(); + await this.page.locator(this.checkout.blockMount).waitFor({ state: 'visible', timeout: 30_000 }); + } + + async placeBlockOrderExpectReceived(): Promise { + // Block places the WC order first, then confirms the intent with + // redirect:'always' → returns to order-received. + await this.page.locator(this.blockSelectors.placeOrder).click(); + await this.page.waitForURL('**/order-received/**', { timeout: 90_000 }); + } + + async placeBlockOrderExpectError(): Promise { + await this.page.locator(this.blockSelectors.placeOrder).click(); + await expect( + this.page.locator(this.blockSelectors.error).first(), + 'declined card should surface a block error notice', + ).toBeVisible({ timeout: 40_000 }); + await expect(this.page, 'declined card must not reach order-received').not.toHaveURL(/order-received/); + } + + // ---- 3DS / SCA ---- + + /** + * Complete the Stripe test 3DS/SCA challenge. The challenge renders in deeply + * nested cross-origin iframes; Playwright flattens them into page.frames(), so + * poll every frame for the test "Complete authentication" button and click it. + */ + /** + * Complete the Stripe test 3DS/SCA challenge. The challenge renders in a Stripe + * ACS test frame (`testmode-acs.stripe.com/3d_secure_2_test/…`) with a + * "Complete" button. Poll every frame (it's deeply nested + cross-origin) and + * click it. NOTE: completing the challenge settles the ORDER server-side, but + * the browser doesn't reliably redirect to order-received in automation — so + * callers assert the order STATUS, not the URL. + */ + async complete3DSChallenge(): Promise { + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + for (const frame of this.page.frames()) { + for (const name of [/complete authentication/i, /^complete$/i, /authorize test payment/i]) { + const btn = frame.getByRole('button', { name }).first(); + if ((await btn.count().catch(() => 0)) > 0) { + await btn.click({ timeout: 8_000 }).catch(() => undefined); + // Confirm the auth was submitted — the ACS frame navigates away, + // detaching the button. If it lingers, the click didn't register + // (the flake), so click once more. + await btn.waitFor({ state: 'detached', timeout: 12_000 }).catch(async () => { + await btn.click({ timeout: 5_000 }).catch(() => undefined); + }); + await this.page.waitForTimeout(2_000); // let Stripe process the auth + return; + } + } + } + await this.page.waitForTimeout(500); + } + throw new Error('Stripe 3DS challenge "Complete" button not found'); + } +} diff --git a/tests/pw/utils/dbUtils.ts b/tests/pw/utils/dbUtils.ts index 8199536053..b6fcb032a0 100644 --- a/tests/pw/utils/dbUtils.ts +++ b/tests/pw/utils/dbUtils.ts @@ -101,6 +101,70 @@ export const dbUtils = { return [currentMetaValue, newMetaValue]; }, + /** + * Seed a vendor as Stripe-Connect-connected WITHOUT the Stripe OAuth login. + * Writes exactly what the OAuth callback does (RegisterWithdrawMethods.php:218-223): + * `dokan_connected_vendor_id` + `_stripe_connect_access_key` (+ the + * `dokan_profile_settings[payment][stripe]` flag for parity). + * + * `suppressValidation` (default true) pre-sets the weekly access-key check + * transient so a placeholder access key is NOT validated against Stripe (and + * thus NOT wiped) if the seeded vendor's dashboard is loaded during a test + * (dokan-pro module.php:193-238 deletes both meta on a failed Account::retrieve). + * + * NOTE: a placeholder `accountId` is enough for the gateway to show at + * checkout and for the card charge (charged on the platform account). The + * later vendor `Transfer::create` needs a REAL Stripe test connected account + * — pass a genuine `acct_…` for money/split/refund assertions. + */ + async seedStripeConnectedVendor( + vendorId: string | number, + opts: { accountId: string; accessKey?: string; suppressValidation?: boolean }, + ): Promise { + const { accountId, accessKey = 'placeholder_access_key', suppressValidation = true } = opts; + const id = String(vendorId); + await dbUtils.setUserMeta(id, 'dokan_connected_vendor_id', accountId); + await dbUtils.setUserMeta(id, '_stripe_connect_access_key', accessKey); + // Optional parity flag (not read by checkout/availability/splits); best-effort. + try { + await dbUtils.updateUserMeta(id, 'dokan_profile_settings', { payment: { stripe: 1 } }, true); + } catch { + await dbUtils.setUserMeta(id, 'dokan_profile_settings', { payment: { stripe: 1 } }, true); + } + if (suppressValidation) { + const name = `dokan_check_stripe_access_key_valid_${vendorId}`; + const timeout = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await dbUtils.setOptionValue(`_transient_${name}`, 'sent', false); + await dbUtils.setOptionValue(`_transient_timeout_${name}`, String(timeout), false); + } + }, + + /** + * Empty a logged-in customer's WooCommerce cart so a fresh browser context + * starts clean. A logged-in cart lives in TWO places — the + * `_woocommerce_persistent_cart_*` user meta AND the live session row in + * `_woocommerce_sessions` (keyed by user id) — so both must be cleared, or + * leftover items from prior tests (e.g. declined-card tests that never + * complete) pollute later checkouts. + */ + async clearCustomerCart(userId: string | number): Promise { + await dbUtils.dbQuery(`DELETE FROM ${dbPrefix}_usermeta WHERE user_id = ? AND meta_key LIKE '_woocommerce_persistent_cart_%';`, [ + String(userId), + ]); + await dbUtils.dbQuery(`DELETE FROM ${dbPrefix}_woocommerce_sessions WHERE session_key = ?;`, [String(userId)]); + }, + + /** Undo seedStripeConnectedVendor — restores the vendor to "not connected". */ + async removeStripeConnectedVendor(vendorId: string | number): Promise { + const id = String(vendorId); + await dbUtils.dbQuery( + `DELETE FROM ${dbPrefix}_usermeta WHERE user_id = ? AND meta_key IN ('dokan_connected_vendor_id', '_stripe_connect_access_key');`, + [id], + ); + const name = `dokan_check_stripe_access_key_valid_${vendorId}`; + await dbUtils.dbQuery(`DELETE FROM ${dbPrefix}_options WHERE option_name IN (?, ?);`, [`_transient_${name}`, `_transient_timeout_${name}`]); + }, + // get option value async getOptionValue(optionName: string): Promise { const query = `Select option_value FROM ${dbPrefix}_options WHERE option_name = ?;`; diff --git a/tests/pw/utils/shard-durations.json b/tests/pw/utils/shard-durations.json index fb165f8f3b..00f19c6f65 100644 --- a/tests/pw/utils/shard-durations.json +++ b/tests/pw/utils/shard-durations.json @@ -7,6 +7,11 @@ "ms": 365840, "tests": 16 }, + { + "file": "stripe-connect/stripeConnect.spec.ts", + "ms": 270000, + "tests": 21 + }, { "file": "abuse-reports/abuseReports.spec.ts", "ms": 294185, diff --git a/tests/pw/utils/stripeApi.ts b/tests/pw/utils/stripeApi.ts new file mode 100644 index 0000000000..4d9f853ac2 --- /dev/null +++ b/tests/pw/utils/stripeApi.ts @@ -0,0 +1,73 @@ +import { request } from '@playwright/test'; + +// The suite's strict tsconfig doesn't include @types/node — declare process locally. +declare const process: { env: Record }; + +const STRIPE_API = 'https://api.stripe.com'; +const secretKey = (): string => process.env.TEST_SECRET_KEY_STRIPE_CONNECT || ''; + +/** + * Thin client for asserting money truth against the Stripe test API (platform + * secret key). Used by the multi-vendor split / refund specs to verify transfers + * actually happened — the Dashboard/API is the source of truth, not the WP UI. + */ +async function stripeGet(path: string): Promise { + const ctx = await request.newContext({ + baseURL: STRIPE_API, + extraHTTPHeaders: { Authorization: `Bearer ${secretKey()}` }, + }); + try { + const res = await ctx.get(path); + const body = await res.json(); + if (!res.ok()) { + throw new Error(`Stripe API ${path} → ${res.status()}: ${JSON.stringify(body?.error ?? body)}`); + } + return body; + } finally { + await ctx.dispose(); + } +} + +export const stripeApi = { + hasSecretKey: (): boolean => Boolean(secretKey()), + + async getPaymentIntent(intentId: string): Promise { + return stripeGet(`/v1/payment_intents/${intentId}`); + }, + + /** Resolve the charge id for a PaymentIntent (its latest_charge). */ + async getLatestChargeId(intentId: string): Promise { + const pi = await this.getPaymentIntent(intentId); + const charge = typeof pi.latest_charge === 'string' ? pi.latest_charge : pi.latest_charge?.id; + if (!charge) { + throw new Error(`PaymentIntent ${intentId} has no latest_charge (status: ${pi.status})`); + } + return charge; + }, + + /** All transfers to a connected account (Stripe has no source_transaction filter, so filter client-side). */ + async listTransfersToDestination(destination: string, limit = 100): Promise { + const body = await stripeGet(`/v1/transfers?destination=${destination}&limit=${limit}`); + return (body.data ?? []) as any[]; + }, + + /** Transfers funded by a specific charge that landed on a specific vendor account. */ + async transfersForChargeToVendor(chargeId: string, vendorAccount: string): Promise { + const all = await this.listTransfersToDestination(vendorAccount); + return all.filter(t => t.source_transaction === chargeId); + }, + + async getTransfer(transferId: string): Promise { + return stripeGet(`/v1/transfers/${transferId}`); + }, + + async getCharge(chargeId: string): Promise { + return stripeGet(`/v1/charges/${chargeId}`); + }, + + /** Find the payment_intent.succeeded event for a given PaymentIntent (for webhook replay tests). */ + async findPaymentIntentSucceededEvent(intentId: string): Promise { + const body = await stripeGet(`/v1/events?type=payment_intent.succeeded&limit=100`); + return (body.data ?? []).find((e: { data?: { object?: { id?: string } } }) => e?.data?.object?.id === intentId); + }, +};