975 lines
51 KiB
TypeScript
975 lines
51 KiB
TypeScript
import { expect, test, type Locator, type Page } from "./utils/test";
|
|
import { getTenantCredentials, loginAsTenantUser, tenantSettingsAccountPath } from "./utils/auth";
|
|
import { expectNoCriticalA11yViolations } from "./utils/axe";
|
|
import { compactMobileViewport, expectNoHorizontalOverflow, narrowMobileViewport } from "./utils/mobile";
|
|
import { escapeRegExp, failOnStripeNetworkRequests, interceptCurrentRoutePost, postDataIncludesField, singleFetchActionError, skipIfUnavailable } from "./utils/navigation";
|
|
import { skipOrFailWhenE2eFixtureMissing, skipOrFailWhenE2eInputsMissing, skipWhenE2eOperatorOptInMissing } from "./utils/readiness";
|
|
|
|
const credentials = getTenantCredentials();
|
|
const dashboardPath = process.env.E2E_TENANT_DASHBOARD_PATH ?? "/app/dashboard";
|
|
const subscriptionPath = process.env.E2E_TENANT_SUBSCRIPTION_PATH ?? tenantSettingsSubscriptionPath(dashboardPath);
|
|
const subscriptionSuccessPath = process.env.E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH;
|
|
const requireReadinessE2e = process.env.REQUIRE_E2E_READINESS === "1";
|
|
const requireSubscriptionSuccessE2e = process.env.E2E_REQUIRE_SUBSCRIPTION_SUCCESS === "1";
|
|
|
|
test.describe("subscription settings smoke", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await failOnStripeNetworkRequests(page);
|
|
});
|
|
|
|
test("renders billing surface without opening Stripe redirects", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"], "Subscription smoke inputs not configured");
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
const products = page.locator('[data-ui-id="ui.subscription.products"]');
|
|
await expect(products).toBeVisible();
|
|
if (requireReadinessE2e) {
|
|
await expect(products).toContainText("E2E Readiness Plan");
|
|
}
|
|
await expect(page.locator('[data-ui-id="ui.subscription.billing.portal"]')).toBeVisible();
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if ((await checkoutLink.count()) > 0) {
|
|
await expect(checkoutLink).toHaveAttribute("href", /\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await expect(checkoutLink).not.toHaveAttribute("href", /stripe\.com|^https?:\/\//u);
|
|
}
|
|
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("opens the tenant checkout catalog from subscription settings without triggering Stripe", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription checkout smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if (!(await isVisible(checkoutLink))) {
|
|
skipOrFailWhenE2eFixtureMissing("No checkout catalog link rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
const checkoutHref = await checkoutLink.getAttribute("href");
|
|
expect(checkoutHref).toMatch(/^\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), checkoutLink.click()]);
|
|
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.page"]').first()).toBeVisible();
|
|
const backLink = page.locator('[data-ui-id="ui.subscription.checkout.backLink"]').first();
|
|
await expect(backLink).toBeVisible();
|
|
await expect(backLink).toHaveAttribute("href", /\/settings\/subscription(?:[?#].*)?$/u);
|
|
await expect(page.getByRole("heading").first()).toBeVisible();
|
|
const checkoutButton = page.locator('[data-ui-id="ui.subscription.checkout.submit"]').first();
|
|
await expect(checkoutButton).toBeVisible();
|
|
await expect(checkoutButton).toHaveAttribute("data-product-id", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("type", "button");
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("submits checkout catalog action contract with tenant context and CSRF", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription checkout action smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if (!(await isVisible(checkoutLink))) {
|
|
skipOrFailWhenE2eFixtureMissing("No checkout catalog link rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), checkoutLink.click()]);
|
|
|
|
const checkoutPage = page.locator('[data-ui-id="ui.subscription.checkout.page"]').first();
|
|
// Clicking the checkout link is an SPA navigation, so waitForLoadState resolves
|
|
// immediately; wait for the /subscribe surface and an enabled submit button to
|
|
// render before the one-shot visibility guard so we don't false-trip it.
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await expect(checkoutPage).toBeVisible();
|
|
const checkoutButton = checkoutPage.locator('[data-ui-id="ui.subscription.checkout.submit"]:not([disabled])').first();
|
|
await checkoutButton.waitFor({ state: "visible", timeout: 10_000 }).catch(() => undefined);
|
|
if (!(await isVisible(checkoutButton))) {
|
|
skipOrFailWhenE2eFixtureMissing("No enabled checkout submit button rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await expect(checkoutButton).toHaveAttribute("data-product-id", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-billing-period", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-currency", /.+/u);
|
|
const expectedProductId = (await checkoutButton.getAttribute("data-product-id")) ?? "";
|
|
const expectedBillingPeriod = (await checkoutButton.getAttribute("data-billing-period")) ?? "";
|
|
const expectedCurrency = (await checkoutButton.getAttribute("data-currency")) ?? "";
|
|
const quantityInput = checkoutPage.locator('[data-ui-id="ui.subscription.checkout.quantity"] input[type="number"]').first();
|
|
let expectedQuantity = "1";
|
|
if (await isVisible(quantityInput)) {
|
|
await expect(quantityInput).toHaveAttribute("min", "1");
|
|
await expect(quantityInput).toHaveAttribute("max", "99");
|
|
await quantityInput.focus();
|
|
await expect(quantityInput).toBeFocused();
|
|
await quantityInput.fill("3");
|
|
expectedQuantity = "3";
|
|
}
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
await checkoutButton.focus();
|
|
await expect(checkoutButton).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
|
|
await confirmPlanChangeIfShown(page);
|
|
await expect.poll(() => submittedPostData.value, { message: "checkout catalog POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^subscribe$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "product-id", exactFieldValue(expectedProductId))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "billing-period", exactFieldValue(expectedBillingPeriod))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "currency", exactFieldValue(expectedCurrency))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "quantity", exactFieldValue(expectedQuantity))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "tenant-slug-context", /.+/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
});
|
|
|
|
test("keeps checkout catalog on-page when checkout action returns an error", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription checkout error smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if (!(await isVisible(checkoutLink))) {
|
|
skipOrFailWhenE2eFixtureMissing("No checkout catalog link rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), checkoutLink.click()]);
|
|
|
|
const checkoutPage = page.locator('[data-ui-id="ui.subscription.checkout.page"]').first();
|
|
// Clicking the checkout link is an SPA navigation, so waitForLoadState resolves
|
|
// immediately; wait for the /subscribe surface and an enabled submit button to
|
|
// render before the one-shot visibility guard so we don't false-trip it.
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await expect(checkoutPage).toBeVisible();
|
|
const checkoutButton = checkoutPage.locator('[data-ui-id="ui.subscription.checkout.submit"]:not([disabled])').first();
|
|
await checkoutButton.waitFor({ state: "visible", timeout: 10_000 }).catch(() => undefined);
|
|
if (!(await isVisible(checkoutButton))) {
|
|
skipOrFailWhenE2eFixtureMissing("No enabled checkout submit button rendered for this fixture");
|
|
return;
|
|
}
|
|
await expect(checkoutButton).toHaveAttribute("data-product-id", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-billing-period", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-currency", /.+/u);
|
|
const expectedProductId = (await checkoutButton.getAttribute("data-product-id")) ?? "";
|
|
const expectedBillingPeriod = (await checkoutButton.getAttribute("data-billing-period")) ?? "";
|
|
const expectedCurrency = (await checkoutButton.getAttribute("data-currency")) ?? "";
|
|
const quantityInput = checkoutPage.locator('[data-ui-id="ui.subscription.checkout.quantity"] input[type="number"]').first();
|
|
let expectedQuantity = "1";
|
|
if (await isVisible(quantityInput)) {
|
|
await expect(quantityInput).toHaveAttribute("min", "1");
|
|
await expect(quantityInput).toHaveAttribute("max", "99");
|
|
await quantityInput.fill("3");
|
|
expectedQuantity = "3";
|
|
}
|
|
|
|
// The checkout submit is an RR7 single-fetch Form POST to `.data`; a raw JSON
|
|
// 400 breaks the turbo-stream decoder so actionData (and the error dialog)
|
|
// never render. Use the single-fetch error envelope helper instead.
|
|
const submittedPostData = await interceptCurrentRoutePost(page, singleFetchActionError("E2E synthetic checkout failure"));
|
|
await checkoutButton.click();
|
|
|
|
await confirmPlanChangeIfShown(page);
|
|
await expect.poll(() => submittedPostData.value, { message: "checkout catalog POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^subscribe$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "product-id", exactFieldValue(expectedProductId))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "billing-period", exactFieldValue(expectedBillingPeriod))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "currency", exactFieldValue(expectedCurrency))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "quantity", exactFieldValue(expectedQuantity))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "tenant-slug-context", /.+/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
await expect(page.getByRole("dialog").filter({ hasText: "E2E synthetic checkout failure" }).first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
|
|
test("renders checkout success return surface when strict operator opt-in is enabled", async ({ page }) => {
|
|
if (!requireSubscriptionSuccessE2e) {
|
|
skipWhenE2eOperatorOptInMissing(
|
|
"E2E_REQUIRE_SUBSCRIPTION_SUCCESS",
|
|
"subscription success return smoke requires an externally acquired Stripe checkout session path"
|
|
);
|
|
return;
|
|
}
|
|
if (!subscriptionSuccessPath) {
|
|
throw new Error(
|
|
"Subscription checkout success smoke requires E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
const missing = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_DASHBOARD_PATH"].filter((key) => !process.env[key]);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Subscription checkout success smoke inputs not configured: missing ${missing.join(", ")} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD"],
|
|
"Subscription checkout success smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionSuccessPath!, { waitUntil: "domcontentloaded" });
|
|
if ([400, 403, 404].includes(response?.status() ?? 0)) {
|
|
throw new Error(
|
|
`Subscription checkout success path is not reachable in this environment: returned ${response?.status()} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
await skipIfUnavailable(page, response, "Subscription checkout success path is not reachable in this environment");
|
|
|
|
const successPage = page.locator('[data-ui-id="ui.subscription.checkout.success.page"]');
|
|
const invalidPage = page.locator('[data-ui-id="ui.subscription.checkout.success.invalid"], [data-ui-id="ui.subscription.checkout.success.error"]');
|
|
if ((await invalidPage.count()) > 0 && (await invalidPage.first().isVisible())) {
|
|
throw new Error(
|
|
"Configured checkout success path did not resolve to a valid acquired checkout session while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
|
|
await expect(successPage).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.receipt"]')).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.nextSteps"]')).toBeVisible();
|
|
const dashboardLink = page.locator('[data-ui-id="ui.subscription.checkout.success.dashboardLink"]').first();
|
|
const subscriptionLink = page.locator('[data-ui-id="ui.subscription.checkout.success.subscriptionLink"]').first();
|
|
await expect(dashboardLink).toHaveAttribute("href", /\/dashboard(?:[?#].*)?$/u);
|
|
await expect(subscriptionLink).toHaveAttribute("href", /\/settings\/subscription(?:[?#].*)?$/u);
|
|
const subscriptionHref = await subscriptionLink.getAttribute("href");
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
await Promise.all([page.waitForURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u")), dashboardLink.click()]);
|
|
await expectTenantDashboard(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
|
|
await page.goto(subscriptionHref ?? subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await expect(page).toHaveURL(/\/settings\/subscription(?:[?#].*)?$/u);
|
|
await expectActiveSubscriptionStateAffordances(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
|
|
test("reconciles checkout success state so it persists across reload and re-login", async ({ page }) => {
|
|
if (!requireSubscriptionSuccessE2e) {
|
|
skipWhenE2eOperatorOptInMissing(
|
|
"E2E_REQUIRE_SUBSCRIPTION_SUCCESS",
|
|
"subscription reconciliation followthrough requires an externally acquired Stripe checkout session path"
|
|
);
|
|
return;
|
|
}
|
|
if (!subscriptionSuccessPath) {
|
|
throw new Error(
|
|
"Subscription checkout reconciliation smoke requires E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
const missing = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_DASHBOARD_PATH"].filter((key) => !process.env[key]);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Subscription checkout reconciliation smoke inputs not configured: missing ${missing.join(", ")} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD"],
|
|
"Subscription checkout reconciliation smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionSuccessPath!, { waitUntil: "domcontentloaded" });
|
|
if ([400, 403, 404].includes(response?.status() ?? 0)) {
|
|
throw new Error(
|
|
`Subscription checkout success path is not reachable in this environment: returned ${response?.status()} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
await skipIfUnavailable(page, response, "Subscription checkout success path is not reachable in this environment");
|
|
|
|
const successPage = page.locator('[data-ui-id="ui.subscription.checkout.success.page"]');
|
|
const invalidPage = page.locator('[data-ui-id="ui.subscription.checkout.success.invalid"], [data-ui-id="ui.subscription.checkout.success.error"]');
|
|
if ((await invalidPage.count()) > 0 && (await invalidPage.first().isVisible())) {
|
|
throw new Error(
|
|
"Configured checkout success path did not resolve to a valid acquired checkout session while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
|
|
await expect(successPage).toBeVisible();
|
|
const dashboardLink = page.locator('[data-ui-id="ui.subscription.checkout.success.dashboardLink"]').first();
|
|
const subscriptionLink = page.locator('[data-ui-id="ui.subscription.checkout.success.subscriptionLink"]').first();
|
|
await expect(dashboardLink).toHaveAttribute("href", /\/dashboard(?:[?#].*)?$/u);
|
|
await expect(subscriptionLink).toHaveAttribute("href", /\/settings\/subscription(?:[?#].*)?$/u);
|
|
const subscriptionHref = await subscriptionLink.getAttribute("href");
|
|
|
|
// Follow the dashboard link from the checkout return surface.
|
|
await Promise.all([page.waitForURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u")), dashboardLink.click()]);
|
|
await expectTenantDashboard(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
|
|
// Reconciled state must be readable on the subscription settings loader and survive a fresh reload.
|
|
await page.goto(subscriptionHref ?? subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await expect(page).toHaveURL(/\/settings\/subscription(?:[?#].*)?$/u);
|
|
await expectActiveSubscriptionStateAffordances(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(page).toHaveURL(/\/settings\/subscription(?:[?#].*)?$/u);
|
|
await expectActiveSubscriptionStateAffordances(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
|
|
// Reconciled state must persist across a full logout + re-login (fresh appData load).
|
|
await signOutTenantUser(page);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const reloginResponse = await page.goto(subscriptionHref ?? subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
if ([400, 403, 404].includes(reloginResponse?.status() ?? 0)) {
|
|
throw new Error(
|
|
`Subscription settings path is not reachable after re-login: returned ${reloginResponse?.status()} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
await skipIfUnavailable(page, reloginResponse, "Subscription settings path is not reachable in this environment");
|
|
await expect(page).toHaveURL(/\/settings\/subscription(?:[?#].*)?$/u);
|
|
await expectActiveSubscriptionStateAffordances(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
|
|
test("renders synthetic invalid checkout return fixture only when operator-enabled", async ({ page }) => {
|
|
skipWhenE2eOperatorOptInMissing(
|
|
"E2E_ALLOW_SYNTHETIC_CHECKOUT_RETURN_SURFACE",
|
|
"synthetic checkout return fixtures do not exercise the real app loader"
|
|
);
|
|
const invalidReturnPath = syntheticCheckoutReturnPath("e2e-invalid-checkout-session");
|
|
await fulfillCheckoutReturnSurface(page, invalidReturnPath, "invalid");
|
|
|
|
const response = await page.goto(invalidReturnPath, { waitUntil: "domcontentloaded" });
|
|
expect(response?.status() ?? 200).toBe(200);
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(invalidReturnPath)}(?:[?#].*)?$`, "u"));
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.invalid"]')).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.subscriptionLink"]')).toHaveAttribute(
|
|
"href",
|
|
/\/settings\/subscription(?:[?#].*)?$/u
|
|
);
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("renders synthetic checkout return error fixture only when operator-enabled", async ({ page }) => {
|
|
skipWhenE2eOperatorOptInMissing(
|
|
"E2E_ALLOW_SYNTHETIC_CHECKOUT_RETURN_SURFACE",
|
|
"synthetic checkout return fixtures do not exercise the real app loader"
|
|
);
|
|
const errorReturnPath = syntheticCheckoutReturnPath("e2e-error-checkout-session");
|
|
await fulfillCheckoutReturnSurface(page, errorReturnPath, "error");
|
|
|
|
const response = await page.goto(errorReturnPath, { waitUntil: "domcontentloaded" });
|
|
expect(response?.status() ?? 200).toBe(500);
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(errorReturnPath)}(?:[?#].*)?$`, "u"));
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.error"]')).toBeVisible();
|
|
await expect(page.getByText("E2E synthetic checkout return failure")).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.subscriptionLink"]')).toHaveAttribute(
|
|
"href",
|
|
/\/settings\/subscription(?:[?#].*)?$/u
|
|
);
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("submits the billing portal action with CSRF without requiring Stripe", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription billing portal smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
|
|
const portalButton = page.locator('[data-ui-id="ui.subscription.billing.portal"]').first();
|
|
await expect(portalButton).toHaveAttribute("type", "button");
|
|
await portalButton.focus();
|
|
await expect(portalButton).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect.poll(() => submittedPostData.value, { message: "billing portal POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^open-customer-portal$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
});
|
|
|
|
test("announces billing portal errors and keeps controls usable", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription billing portal error inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const portalButton = page.locator('[data-ui-id="ui.subscription.billing.portal"]').first();
|
|
await expect(portalButton).toBeVisible();
|
|
await expect(portalButton).toHaveAttribute("type", "button");
|
|
|
|
const syntheticError = "E2E synthetic billing portal failure";
|
|
const submittedPostData = await interceptCurrentRoutePost(page, singleFetchActionError(syntheticError));
|
|
|
|
await portalButton.click();
|
|
|
|
await expect.poll(() => submittedPostData.value, { message: "billing portal error POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^open-customer-portal$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
await expect(page.getByRole("status").filter({ hasText: syntheticError }).first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(subscriptionPath)}(?:[?#].*)?$`, "u"));
|
|
await expect(portalButton).toBeEnabled();
|
|
await portalButton.focus();
|
|
await expect(portalButton).toBeFocused();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
|
|
test("submits cancellation action contract with CSRF when cancellation controls are rendered", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription cancellation smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const cancelButton = await findVisibleCancellationButton(page);
|
|
if (!cancelButton) {
|
|
skipOrFailWhenE2eFixtureMissing("No visible subscription cancellation controls rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
|
|
await cancelButton.click();
|
|
const dialog = page
|
|
.locator('[role="alertdialog"]')
|
|
.filter({ has: page.locator("button") })
|
|
.last();
|
|
await expect(dialog).toBeVisible();
|
|
const confirmAction = dialog.locator('[data-ui-id="ui.confirm.action"]').first();
|
|
await expect(confirmAction).toBeVisible();
|
|
await confirmAction.click();
|
|
|
|
await expect.poll(() => submittedPostData.value, { message: "subscription cancellation POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^cancel$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "tenant-subscription-product-id", /.+/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
});
|
|
|
|
test("does not submit cancellation when the confirmation dialog is dismissed", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription cancellation dismiss smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const cancelButton = await findVisibleCancellationButton(page);
|
|
if (!cancelButton) {
|
|
skipOrFailWhenE2eFixtureMissing("No visible subscription cancellation controls rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
await cancelButton.click();
|
|
const dialog = page
|
|
.locator('[role="alertdialog"]')
|
|
.filter({ has: page.locator("button") })
|
|
.last();
|
|
await expect(dialog).toBeVisible();
|
|
const dismissAction = dialog.locator('[data-ui-id="ui.confirm.cancel"]').first();
|
|
if (await isVisible(dismissAction)) {
|
|
await dismissAction.click();
|
|
} else {
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForTimeout(250);
|
|
expect(submittedPostData.value).toBeNull();
|
|
});
|
|
|
|
test("opens cancellation confirmation by keyboard and dismisses it with Escape without posting", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription cancellation keyboard smoke inputs not configured"
|
|
);
|
|
expect(process.env.STRIPE_SK ?? "").not.toMatch(/^sk_live_/u);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const cancelButton = await findVisibleCancellationButton(page);
|
|
if (!cancelButton) {
|
|
skipOrFailWhenE2eFixtureMissing("No visible subscription cancellation controls rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
await expect(cancelButton).toHaveAttribute("type", "button");
|
|
await expect(cancelButton).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
|
|
const dialog = page.locator('[data-ui-id="ui.subscription.product.cancel.dialog"]').last();
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toHaveAttribute("role", "alertdialog");
|
|
await expect(dialog.locator('[data-ui-id="ui.confirm.action"]').first()).toBeVisible();
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForTimeout(250);
|
|
expect(submittedPostData.value).toBeNull();
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
});
|
|
|
|
test.describe("subscription settings mobile smoke", () => {
|
|
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
|
|
test.beforeEach(async ({ page }) => {
|
|
await failOnStripeNetworkRequests(page);
|
|
});
|
|
|
|
test("keeps subscription controls reachable on mobile", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription mobile smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
const products = page.locator('[data-ui-id="ui.subscription.products"]');
|
|
await expect(products).toBeVisible();
|
|
if (requireReadinessE2e) {
|
|
await expect(products).toContainText("E2E Readiness Plan");
|
|
}
|
|
|
|
const historyToggle = page.locator('[data-ui-id="ui.subscription.product.history.toggle"]').first();
|
|
if (await isVisible(historyToggle)) {
|
|
await historyToggle.focus();
|
|
await expect(historyToggle).toBeFocused();
|
|
await expect(historyToggle).toHaveAttribute("aria-expanded", "false");
|
|
await page.keyboard.press("Enter");
|
|
await expect(historyToggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(page.locator('[data-ui-id="ui.subscription.product.history"]').first()).toBeVisible();
|
|
}
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.billing.portal"]')).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("submits the billing portal action by touch on mobile", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription mobile billing portal inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
const portalButton = page.locator('[data-ui-id="ui.subscription.billing.portal"]').first();
|
|
await expect(portalButton).toBeVisible();
|
|
await expect(portalButton).toHaveAttribute("type", "button");
|
|
await portalButton.tap();
|
|
|
|
await expect.poll(() => submittedPostData.value, { message: "mobile billing portal POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^open-customer-portal$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test("follows checkout success return into the tenant dashboard on mobile when strict mode is enabled", async ({ page }) => {
|
|
if (!requireSubscriptionSuccessE2e) {
|
|
skipWhenE2eOperatorOptInMissing(
|
|
"E2E_REQUIRE_SUBSCRIPTION_SUCCESS",
|
|
"skipping strict checkout success mobile follow-through"
|
|
);
|
|
return;
|
|
}
|
|
if (!subscriptionSuccessPath) {
|
|
throw new Error(
|
|
"Subscription checkout success mobile smoke requires E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_DASHBOARD_PATH"],
|
|
"Subscription checkout success mobile smoke inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionSuccessPath, { waitUntil: "domcontentloaded" });
|
|
if ([400, 403, 404].includes(response?.status() ?? 0)) {
|
|
throw new Error(
|
|
`Subscription checkout success path is not reachable in this environment: returned ${response?.status()} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`
|
|
);
|
|
}
|
|
await skipIfUnavailable(page, response, "Subscription checkout success path is not reachable in this environment");
|
|
|
|
const invalidPage = page.locator('[data-ui-id="ui.subscription.checkout.success.invalid"], [data-ui-id="ui.subscription.checkout.success.error"]');
|
|
if ((await invalidPage.count()) > 0 && (await invalidPage.first().isVisible())) {
|
|
throw new Error(
|
|
"Configured checkout success path did not resolve to a valid acquired checkout session while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1"
|
|
);
|
|
}
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.page"]')).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.success.receipt"]')).toBeVisible();
|
|
const dashboardLink = page.locator('[data-ui-id="ui.subscription.checkout.success.dashboardLink"]').first();
|
|
await expect(dashboardLink).toHaveAttribute("href", /\/dashboard(?:[?#].*)?$/u);
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
await Promise.all([page.waitForURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u")), dashboardLink.tap()]);
|
|
await expectTenantDashboard(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
});
|
|
|
|
test.describe("subscription settings compact mobile smoke", () => {
|
|
test.use({ viewport: compactMobileViewport, isMobile: true, hasTouch: true });
|
|
test.beforeEach(async ({ page }) => {
|
|
await failOnStripeNetworkRequests(page);
|
|
});
|
|
|
|
test("keeps account settings, subscription settings, and checkout catalog usable at 320px", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_DASHBOARD_PATH", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription compact mobile inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const accountPath = tenantSettingsAccountPath(dashboardPath);
|
|
const accountResponse = await page.goto(accountPath, { waitUntil: "domcontentloaded" });
|
|
expect(accountResponse?.status() ?? 200).toBeLessThan(500);
|
|
await skipIfUnavailable(page, accountResponse, `Account settings path ${accountPath}`);
|
|
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(accountPath)}(?:[?#].*)?$`, "u"));
|
|
await expect(page.locator('[data-ui-id="ui.settings.account.name"], form input[name="name"]').first()).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.settings.account.slug"], form input[name="slug"]').first()).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.settings.account.submit"], form button[type="submit"]').first()).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.products"]')).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.billing.portal"]')).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if (!(await isVisible(checkoutLink))) {
|
|
skipOrFailWhenE2eFixtureMissing("No checkout catalog link rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await expect(checkoutLink).toHaveAttribute("href", /\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), checkoutLink.tap()]);
|
|
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.page"]').first()).toBeVisible();
|
|
await expect(page.locator('[data-ui-id="ui.subscription.checkout.submit"]').first()).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
});
|
|
|
|
test("submits checkout catalog action contract at 320px", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription compact checkout action inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
const checkoutLink = page.locator('[data-ui-id="ui.subscription.checkout.link"]').first();
|
|
if (!(await isVisible(checkoutLink))) {
|
|
skipOrFailWhenE2eFixtureMissing("No checkout catalog link rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), checkoutLink.tap()]);
|
|
|
|
// SPA navigation: wait for the /subscribe surface and an enabled submit button
|
|
// before the one-shot visibility guard so we don't false-trip it.
|
|
await expect(page).toHaveURL(/\/subscribe\/[^/?#]+(?:[?#].*)?$/u);
|
|
const checkoutButton = page.locator('[data-ui-id="ui.subscription.checkout.submit"]:not([disabled])').first();
|
|
await checkoutButton.waitFor({ state: "visible", timeout: 10_000 }).catch(() => undefined);
|
|
if (!(await isVisible(checkoutButton))) {
|
|
skipOrFailWhenE2eFixtureMissing("No enabled checkout submit button rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await expect(checkoutButton).toHaveAttribute("data-product-id", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-billing-period", /.+/u);
|
|
await expect(checkoutButton).toHaveAttribute("data-currency", /.+/u);
|
|
const expectedProductId = (await checkoutButton.getAttribute("data-product-id")) ?? "";
|
|
const expectedBillingPeriod = (await checkoutButton.getAttribute("data-billing-period")) ?? "";
|
|
const expectedCurrency = (await checkoutButton.getAttribute("data-currency")) ?? "";
|
|
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
const submittedPostData = await interceptCurrentRoutePost(page);
|
|
await checkoutButton.tap();
|
|
|
|
await confirmPlanChangeIfShown(page);
|
|
await expect.poll(() => submittedPostData.value, { message: "compact checkout catalog POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(submittedPostData.value, "action", /^subscribe$/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "product-id", exactFieldValue(expectedProductId))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "billing-period", exactFieldValue(expectedBillingPeriod))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "currency", exactFieldValue(expectedCurrency))).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "tenant-slug-context", /.+/u)).toBe(true);
|
|
expect(postDataIncludesField(submittedPostData.value, "csrf", /.+/u)).toBe(true);
|
|
});
|
|
|
|
test("keeps billing portal and cancellation controls usable at 320px", async ({ page }) => {
|
|
skipOrFailWhenE2eInputsMissing(
|
|
["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_TENANT_SUBSCRIPTION_PATH"],
|
|
"Subscription compact portal and cancellation inputs not configured"
|
|
);
|
|
await loginAsTenantUser(page, credentials!);
|
|
|
|
const response = await page.goto(subscriptionPath, { waitUntil: "domcontentloaded" });
|
|
await skipIfUnavailable(page, response, "Subscription settings path is not reachable in this environment");
|
|
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
const portalButton = page.locator('[data-ui-id="ui.subscription.billing.portal"]').first();
|
|
await expect(portalButton).toBeVisible();
|
|
await expect(portalButton).toHaveAttribute("type", "button");
|
|
|
|
const cancelButton = await findVisibleCancellationButton(page);
|
|
if (!cancelButton) {
|
|
skipOrFailWhenE2eFixtureMissing("No visible subscription cancellation controls rendered for this fixture");
|
|
return;
|
|
}
|
|
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
const cancellationPostData = await interceptCurrentRoutePost(page);
|
|
await cancelButton.tap();
|
|
const dialog = page.locator('[data-ui-id="ui.subscription.product.cancel.dialog"]').last();
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toHaveAttribute("role", "alertdialog");
|
|
await expect(dialog.locator('[data-ui-id="ui.confirm.action"]').first()).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
|
|
const dismissAction = dialog.locator('[data-ui-id="ui.confirm.cancel"]').first();
|
|
if (await isVisible(dismissAction)) {
|
|
await dismissAction.tap();
|
|
} else {
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForTimeout(250);
|
|
expect(cancellationPostData.value).toBeNull();
|
|
await page.unroute("**/*");
|
|
|
|
const syntheticError = "E2E compact billing portal failure";
|
|
const portalPostData = await interceptCurrentRoutePost(page, singleFetchActionError(syntheticError));
|
|
await portalButton.tap();
|
|
|
|
await expect.poll(() => portalPostData.value, { message: "compact billing portal POST payload was captured" }).not.toBeNull();
|
|
expect(postDataIncludesField(portalPostData.value, "action", /^open-customer-portal$/u)).toBe(true);
|
|
expect(postDataIncludesField(portalPostData.value, "csrf", /.+/u)).toBe(true);
|
|
await expect(page.getByRole("status").filter({ hasText: syntheticError }).first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
await expect(portalButton).toBeEnabled();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
expect(page.url()).not.toMatch(/stripe\.com/u);
|
|
});
|
|
});
|
|
|
|
async function isVisible(locator: Locator): Promise<boolean> {
|
|
return (await locator.count()) > 0 && (await locator.isVisible());
|
|
}
|
|
|
|
// A seeded tenant with an existing subscription routes plan upgrades through a
|
|
// proration preview modal before the real POST instead of submitting directly.
|
|
// Confirm it when present so the checkout contract capture still observes the
|
|
// "subscribe" POST; first-subscribe fixtures skip straight through.
|
|
async function confirmPlanChangeIfShown(page: Page): Promise<void> {
|
|
const confirm = page.locator('[data-ui-id="ui.subscription.planChange.confirm"]').first();
|
|
await confirm
|
|
.waitFor({ state: "visible", timeout: 2500 })
|
|
.then(() => confirm.click())
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
async function findVisibleCancellationButton(page: Page): Promise<Locator | null> {
|
|
const activeProducts = page.locator('[data-ui-id="ui.subscription.product.active"]').first();
|
|
if (!(await isVisible(activeProducts))) {
|
|
return null;
|
|
}
|
|
|
|
const productCards = activeProducts.locator('[data-ui-id="ui.subscription.product.card"]');
|
|
const productCardCount = await productCards.count();
|
|
for (let index = 0; index < productCardCount; index += 1) {
|
|
const productCard = productCards.nth(index);
|
|
|
|
const button = productCard.locator('[data-ui-id="ui.subscription.product.cancel"]').first();
|
|
if ((await button.count()) > 0) {
|
|
await button.focus();
|
|
await expect(button).toBeVisible();
|
|
return button;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function expectActiveSubscriptionStateAffordances(page: Page): Promise<void> {
|
|
await expect(page.locator('[data-ui-id="ui.subscription.page"]')).toBeVisible();
|
|
const products = page.locator('[data-ui-id="ui.subscription.products"]');
|
|
await expect(products).toBeVisible();
|
|
|
|
const activeProducts = products.locator('[data-ui-id="ui.subscription.product.active"]').first();
|
|
await expect(activeProducts).toBeVisible();
|
|
await expect(activeProducts.locator('[data-ui-id="ui.subscription.product.card"]').first()).toBeVisible();
|
|
|
|
const portalButton = page.locator('[data-ui-id="ui.subscription.billing.portal"]').first();
|
|
await expect(portalButton).toBeVisible();
|
|
await expect(portalButton).toHaveAttribute("type", "button");
|
|
|
|
const historyToggle = page.locator('[data-ui-id="ui.subscription.product.history.toggle"]').first();
|
|
await expect(historyToggle).toBeVisible();
|
|
await expect(historyToggle).toHaveAttribute("aria-expanded", "false");
|
|
await historyToggle.click();
|
|
await expect(historyToggle).toHaveAttribute("aria-expanded", "true");
|
|
await expect(page.locator('[data-ui-id="ui.subscription.product.history"]').first()).toBeVisible();
|
|
|
|
const cancelButton = await findVisibleCancellationButton(page);
|
|
if (!cancelButton) {
|
|
throw new Error("Expected active subscription cancellation controls after checkout success");
|
|
}
|
|
await expect(cancelButton).toHaveAttribute("type", "button");
|
|
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
}
|
|
|
|
async function signOutTenantUser(page: Page): Promise<void> {
|
|
const moreTrigger = page.locator('[data-ui-id="ui.action.shell.more"]').first();
|
|
if (await isVisible(moreTrigger)) {
|
|
await moreTrigger.click();
|
|
}
|
|
const signOut = page.locator('[data-ui-id="ui.action.shell.signOut"]').first();
|
|
await expect(signOut).toBeVisible();
|
|
await Promise.all([page.waitForLoadState("domcontentloaded"), signOut.click()]);
|
|
await expect(page).not.toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
|
|
}
|
|
|
|
async function expectTenantDashboard(page: Page): Promise<void> {
|
|
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
|
|
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
|
|
await expect(page.locator('[data-ui-id="ui.dashboard.page"]').first()).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoCriticalA11yViolations(page);
|
|
}
|
|
|
|
function tenantSettingsSubscriptionPath(path: string): string {
|
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
const marker = "/dashboard";
|
|
if (!normalized.endsWith(marker)) {
|
|
return process.env.E2E_TENANT_SUBSCRIPTION_PATH ?? "/app/settings/subscription";
|
|
}
|
|
return `${normalized.slice(0, -marker.length)}/settings/subscription`;
|
|
}
|
|
|
|
function exactFieldValue(value: string): RegExp {
|
|
return new RegExp(`^${escapeRegExp(value)}$`, "u");
|
|
}
|
|
|
|
function syntheticCheckoutReturnPath(sessionId: string): string {
|
|
const subscriptionSettingsPath = tenantSettingsSubscriptionPath(dashboardPath);
|
|
const normalized = subscriptionSettingsPath.startsWith("/") ? subscriptionSettingsPath : `/${subscriptionSettingsPath}`;
|
|
const marker = "/settings/subscription";
|
|
const tenantBase = normalized.endsWith(marker) ? normalized.slice(0, -marker.length) : "/app";
|
|
const tenantSlug = tenantBase.split("/").filter(Boolean).at(-1) ?? "app";
|
|
return `/subscribe/${tenantSlug}/${sessionId}/success`;
|
|
}
|
|
|
|
async function fulfillCheckoutReturnSurface(page: Page, path: string, variant: "invalid" | "error"): Promise<void> {
|
|
const subscriptionSettingsPath = tenantSettingsSubscriptionPath(dashboardPath);
|
|
await page.route("**/*", async (route) => {
|
|
const requestUrl = new URL(route.request().url());
|
|
if (route.request().method() !== "GET" || requestUrl.pathname !== path) {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
const isError = variant === "error";
|
|
await route.fulfill({
|
|
status: isError ? 500 : 200,
|
|
contentType: "text/html",
|
|
body: `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${isError ? "Checkout return error" : "Invalid checkout session"}</title>
|
|
<style>
|
|
body { margin: 0; font-family: system-ui, sans-serif; color: #111827; background: #ffffff; }
|
|
main { min-height: 100vh; display: grid; place-items: center; padding: 24px 16px; box-sizing: border-box; }
|
|
section { width: min(100%, 520px); text-align: center; }
|
|
h1 { margin: 0; font-size: 1.875rem; line-height: 1.15; }
|
|
p { color: #b91c1c; line-height: 1.5; }
|
|
a { display: inline-flex; min-height: 44px; align-items: center; border: 1px solid #d1d5db; border-radius: 999px; padding: 0 14px; color: #111827; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main data-ui-id="ui.subscription.checkout.success.${variant}">
|
|
<section>
|
|
<h1>${isError ? "Unexpected error" : "Error"}</h1>
|
|
<p>${isError ? "E2E synthetic checkout return failure" : "Invalid checkout session"}</p>
|
|
<a data-ui-id="ui.subscription.checkout.success.subscriptionLink" href="${escapeHtml(subscriptionSettingsPath)}">Back to subscription</a>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
});
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
}
|