Files
pleno-vue/tests/e2e/pos-mobile-card-payments.spec.js
T

575 lines
21 KiB
JavaScript

import { expect, test } from "@playwright/test";
import {
CARD_CUSTOMER_ID,
DEFAULT_ORDER_ID,
REGULAR_CUSTOMER_ID,
createMobilePosFixture,
createPaymentIntent,
getStoredPosSnapshot,
setupMobilePosPage,
suppressVueDevtoolsOverlay,
waitForMobileNextStepCooldown,
} from "./support/mobilePos.js";
test.describe("POS mobile card payments", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "Mobile card payment suite is scoped to chromium-mobile.");
await suppressVueDevtoolsOverlay(page);
});
test("card mode entry sets customer 999 and preserves order context", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-entry-token",
seedState: {
customerId: null,
reg: "ZZ00000",
reference: "CARD-CTX-REF",
includePrimaryItem: false,
vehicleType: 53,
},
route: {
step: 1,
},
});
await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-direct-card-payment").click();
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return snapshot?.metadata?.customerId ?? null;
},
{ timeout: 10_000 }
)
.toBe(CARD_CUSTOMER_ID);
const snapshot = await getStoredPosSnapshot(page);
expect(snapshot?.vehicles?.vehicle_1?.reg).toBe("ZZ00000");
expect(snapshot?.metadata?.reference).toBe("CARD-CTX-REF");
});
test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({ page }) => {
const fixture = createMobilePosFixture({
readers: [],
});
await setupMobilePosPage(page, fixture, {
token: "mobile-card-reader-polling-token",
seedState: {
customerId: null,
reg: "ZZ00000",
reference: "CARD-POLL-REF",
includePrimaryItem: false,
vehicleType: 53,
},
route: {
step: 1,
},
});
await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
const cardButton = page.getByTestId("pos-mobile-direct-card-payment");
await expect(cardButton).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect(cardButton).toBeDisabled();
fixture.readers = [
{
id: "reader_online_2",
label: "Recovered Reader",
status: "online",
action: null,
},
];
await expect
.poll(async () => {
return await cardButton.isEnabled();
}, { timeout: 12_000 })
.toBe(true);
await cardButton.click();
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return snapshot?.metadata?.customerId ?? null;
},
{ timeout: 10_000 }
)
.toBe(CARD_CUSTOMER_ID);
});
test("@smoke direct card flow bridges step 1 to capture and resets to scanner", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-bridge-token",
seedState: {
customerId: null,
reg: "CD99999",
reference: "CARD-BRIDGE-REF",
includePrimaryItem: false,
vehicleType: 53,
},
route: {
step: 1,
},
});
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-direct-card-payment").click();
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await waitForMobileNextStepCooldown(page);
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-capture-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
await expect
.poll(
() => {
const currentUrl = new URL(page.url());
return currentUrl.searchParams.get("step");
},
{ timeout: 12_000 }
)
.toBe("1");
await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 });
});
test("step 2 transitions to mobile card payment step 3 for customer 999", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-step-transition-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
primaryItemId: 53,
},
route: {
step: 2,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(/step=3/);
expect(fixture.requestCounters.markAsCompleted).toBe(0);
});
test("reader unavailable state is recoverable and non-fatal", async ({ page }) => {
const fixture = createMobilePosFixture({
readers: [
{
id: "reader_in_use",
label: "Busy reader",
status: "online",
action: { type: "process_payment_intent" },
},
{
id: "reader_offline",
label: "Offline reader",
status: "offline",
action: null,
},
],
});
await setupMobilePosPage(page, fixture, {
token: "mobile-card-reader-unavailable-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-no-readers")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-reader-unavailable-message")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(page.getByTestId("pos-stripe-error-message")).toHaveCount(0);
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveAttribute("data-status-key", "in_use");
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS(
"background-color",
"rgb(245, 158, 11)"
);
await page.getByTestId("pos-stripe-terminal-trigger").click();
await expect(page.getByTestId("pos-stripe-terminal-menu")).toBeVisible();
await expect(page.getByTestId("pos-stripe-terminal-group-in_use")).toBeVisible();
await expect(page.getByTestId("pos-stripe-terminal-group-offline")).toBeVisible();
await expect(page.getByTestId("pos-stripe-terminal-option-reader_in_use")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("pos-stripe-terminal-option-dot-reader_offline")).toHaveCSS(
"background-color",
"rgb(220, 38, 38)"
);
fixture.readers = [
{
id: "reader_in_use",
label: "Busy reader",
status: "online",
action: { type: "process_payment_intent" },
},
{
id: "reader_offline",
label: "Offline reader",
status: "offline",
action: null,
},
{
id: "reader_online_2",
label: "Recovered Reader",
status: "online",
action: null,
},
];
await page.getByTestId("pos-stripe-no-readers").click();
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-selected-reader")).toContainText("Recovered Reader");
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveAttribute("data-status-key", "ready");
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS(
"background-color",
"rgb(22, 163, 74)"
);
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
});
test("reader available creates intent once and reaches capture state", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-create-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled();
await page.getByTestId("pos-stripe-create-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
expect(fixture.requestCounters.markAsCompleted).toBe(0);
});
test("step 3 back offers resume or cancel when payment is active", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-cancel-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-step-3-back").click();
await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 });
await page.locator(".swal2-confirm").click();
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
expect(fixture.requestCounters.paymentIntentDelete).toBe(0);
await page.getByTestId("pos-mobile-step-3-back").click();
await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 });
await page.locator(".swal2-deny").click();
await expect.poll(() => fixture.requestCounters.paymentIntentDelete, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(/step=2/);
expect(fixture.requestCounters.markAsCompleted).toBe(0);
});
test("capture success completes once and resets scanner flow", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-capture-success-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-capture-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
await expect
.poll(
() => {
const currentUrl = new URL(page.url());
return currentUrl.searchParams.get("step");
},
{ timeout: 12_000 }
)
.toBe("1");
await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 });
});
test("guardrail: completion never runs before capture success", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-guardrail-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 2_500 }).toBe(0);
});
test("step 3 bootstrap recovery fetches existing requires_capture intent", async ({ page }) => {
const fixture = createMobilePosFixture({
paymentIntentsByOrderId: {
[DEFAULT_ORDER_ID]: createPaymentIntent(DEFAULT_ORDER_ID, {
status: "requires_capture",
}),
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-card-recovery-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.requestCounters.paymentIntentGet, { timeout: 10_000 }).toBeGreaterThan(0);
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
});
test("step 3 bootstrap recovery completes already succeeded intent once", async ({ page }) => {
const fixture = createMobilePosFixture({
paymentIntentsByOrderId: {
[DEFAULT_ORDER_ID]: createPaymentIntent(DEFAULT_ORDER_ID, {
status: "succeeded",
}),
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-card-succeeded-recovery-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.requestCounters.paymentIntentGet, { timeout: 10_000 }).toBeGreaterThan(0);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
expect(fixture.requestCounters.paymentIntentCapture).toBe(0);
await expect
.poll(
() => {
const currentUrl = new URL(page.url());
return currentUrl.searchParams.get("step");
},
{ timeout: 12_000 }
)
.toBe("1");
});
test("returning to step 2 and re-entering step 3 does not duplicate order items", async ({ page }) => {
const fixture = createMobilePosFixture();
await setupMobilePosPage(page, fixture, {
token: "mobile-card-step-reentry-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 2,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-step-3-back").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2100);
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.orderItemsGet, { timeout: 10_000 }).toBeGreaterThan(0);
});
test("payment-intent get/create/capture failures surface retryable error states", async ({ page }) => {
const fixture = createMobilePosFixture({
failureBudget: {
paymentIntentGet: 0,
paymentIntentCreate: 2,
paymentIntentCapture: 2,
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-card-error-recovery-token",
seedState: {
customerId: CARD_CUSTOMER_ID,
includePrimaryItem: true,
},
route: {
step: 3,
orderId: DEFAULT_ORDER_ID,
customerId: CARD_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(2);
await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 });
fixture.failureBudget.paymentIntentGet = 2;
await page.getByTestId("pos-stripe-error").click();
await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-error").click();
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-create-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(3);
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-capture-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(2);
await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-error").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-stripe-capture-intent").click();
await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(3);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
});
test("regression: non-card mobile customer still follows step-2 complete/reset path", async ({ page }) => {
const regularCustomerId = REGULAR_CUSTOMER_ID;
const fixture = createMobilePosFixture({
ordersById: {
[DEFAULT_ORDER_ID]: {
id: DEFAULT_ORDER_ID,
customer_id: regularCustomerId,
department_id: 1,
reference: "REF-9201",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
invoice_collection_id: null,
booking_id: null,
completed_at: null,
created_at: "2026-01-01T10:00:00.000Z",
},
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-non-card-regression-token",
seedState: {
customerId: regularCustomerId,
includePrimaryItem: true,
},
route: {
step: 2,
orderId: DEFAULT_ORDER_ID,
customerId: regularCustomerId,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect
.poll(
() => {
const currentUrl = new URL(page.url());
return currentUrl.searchParams.get("step");
},
{ timeout: 12_000 }
)
.toBe("1");
expect(fixture.requestCounters.markAsCompleted).toBe(0);
await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0);
});
});