stack/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
BilalG1 89554a3eed
payment test mode fixes (#957)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- RECURSEML_SUMMARY:START -->
## High-level PR Summary
This PR fixes payment test mode behavior by changing the default test
mode setting to `true` and refactoring the test mode bypass UI. The
bypass functionality is moved from a floating card in the purchase page
into the checkout form itself, providing a cleaner and more integrated
experience. Additionally, the database migration configuration is
updated to increase the `maxWait` timeout to handle concurrent migration
attempts more gracefully in high-contention scenarios like CI
environments.

⏱️ Estimated Review Time: 5-15 minutes

<details>
<summary>💡 Review Order Suggestion</summary>

| Order | File Path |
|-------|-----------|
| 1 | `packages/stack-shared/src/config/schema.ts` |
| 2 | `apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx` |
| 3 | `apps/dashboard/src/components/payments/checkout.tsx` |
| 4 | `apps/backend/src/auto-migrations/index.tsx` |
</details>

<details>
<summary>⚠️ Inconsistent Changes Detected</summary>

| File Path | Warning |
|-----------|---------|
| `apps/backend/src/auto-migrations/index.tsx` | Database migration
timeout configuration changes appear unrelated to payment test mode
fixes, which is the stated purpose of this PR |
</details>

[![Need help? Join our
Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U)


[![Analyze latest
changes](564b2cf6ef/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=957)
<!-- RECURSEML_SUMMARY:END -->
<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Sets payment test mode to default true, integrates test mode bypass
into checkout form, and updates migration timeout.
> 
>   - **Behavior**:
>     - Default `testMode` set to `true` in `schema.ts`.
> - Integrates test mode bypass into `CheckoutForm` in `checkout.tsx`.
>     - Removes separate bypass panel from `page-client.tsx`.
>   - **Database**:
> - Increases `maxWait` timeout in `index.tsx` to handle concurrent
migration attempts.
>   - **Tests**:
> - Updates tests in `backend-helpers.ts` and `validate-code.test.ts` to
reflect test mode behavior.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 6313c0bfed. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a test-mode checkout path with a “Complete test purchase” action
when test mode is active.
* **Refactor**
* Consolidated test-mode bypass into the checkout component and removed
the separate bypass UI.
* **Bug Fixes**
* Improved reliability of database migrations by extending the
transaction wait window, reducing timeout errors under load.
* **Chores**
  * Payments now default to test mode enabled.
* **Tests**
* Updated tests and payload expectations to reflect test mode defaults
and behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-15 19:14:45 -07:00

336 lines
10 KiB
TypeScript

import { it } from "../../../../../helpers";
import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers";
it("should error on invalid code", async ({ expect }) => {
await Project.createAndSwitch();
const response = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: {
full_code: "invalid-code",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": {
"code": "VERIFICATION_CODE_NOT_FOUND",
"error": "The verification code does not exist for this project.",
},
"headers": Headers {
"x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND",
<some fields may have been hidden>,
},
}
`);
});
it("should allow valid code and return product data", async ({ expect }) => {
const { code } = await Payments.createPurchaseUrlAndGetCode();
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code },
});
expect(validateResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"conflicting_products": [],
"product": {
"customer_type": "user",
"display_name": "Test Product",
"included_items": {},
"prices": {
"monthly": {
"USD": "1000",
"interval": [
1,
"month",
],
},
},
"server_only": false,
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": false,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should set already_bought_non_stackable when user already owns non-stackable product", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
products: {
"test-product": {
displayName: "Test Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});
const { userId } = await User.create();
// Create a code for test-product and purchase it in test mode (creates DB subscription)
const createUrlRes1 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: "test-product",
},
});
expect(createUrlRes1.status).toBe(200);
const code1 = (createUrlRes1.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code1).toBeDefined();
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: {
full_code: code1,
price_id: "monthly",
quantity: 1,
},
});
expect(testModeRes.status).toBe(200);
// Create a second code for the same product and validate; should report already_bought_non_stackable
const createUrlRes2 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: "test-product",
},
});
expect(createUrlRes2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "PRODUCT_ALREADY_GRANTED",
"details": {
"customer_id": "<stripped UUID>",
"product_id": "test-product",
},
"error": "Customer with ID \\"<stripped UUID>\\" already owns product \\"test-product\\".",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_ALREADY_GRANTED",
<some fields may have been hidden>,
},
}
`);
});
it("should include conflicting_products when switching within the same group", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
catalogs: { grp: { displayName: "Group" } },
products: {
productA: {
displayName: "Product A",
customerType: "user",
serverOnly: false,
catalogId: "grp",
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
productB: {
displayName: "Product B",
customerType: "user",
serverOnly: false,
catalogId: "grp",
stackable: false,
prices: { monthly: { USD: "2000", interval: [1, "month"] } },
includedItems: {},
},
},
},
});
const { userId } = await User.create();
// Subscribe to productA in test mode
const resUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "productA" },
});
expect(resUrlA.status).toBe(200);
const codeA = (resUrlA.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(codeA).toBeDefined();
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: { full_code: codeA, price_id: "monthly", quantity: 1 },
});
expect(testModeRes.status).toBe(200);
// Now validate code for productB; should report conflict with productA
const resUrlB = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "productB" },
});
expect(resUrlB.status).toBe(200);
const codeB = (resUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(codeB).toBeDefined();
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: codeB },
});
expect(validateResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"conflicting_products": [
{
"display_name": "Product A",
"product_id": "productA",
},
],
"product": {
"customer_type": "user",
"display_name": "Product B",
"included_items": {},
"prices": {
"monthly": {
"USD": "2000",
"interval": [
1,
"month",
],
},
},
"server_only": false,
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should reject untrusted return_url and accept trusted return_url", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
products: {
"test-product": {
displayName: "Test Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
},
},
});
const { userId } = await User.create();
await Project.updateConfig({
domains: {
allowLocalhost: false,
trustedDomains: {
'1': { baseUrl: 'https://stack-test.com', handlerPath: '/handler' },
},
},
});
const createUrlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "test-product" },
});
expect(createUrlRes.status).toBe(200);
const code = (createUrlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code).toBeDefined();
const badRes = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code, return_url: "https://malicious.com/callback" },
});
expect(badRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "REDIRECT_URL_NOT_WHITELISTED",
"error": "Redirect URL not whitelisted. Did you forget to add this domain to the trusted domains list on the Stack Auth dashboard?",
},
"headers": Headers {
"x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED",
<some fields may have been hidden>,
},
}
`);
const goodRes = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code, return_url: "https://stack-test.com/handler" },
});
expect(goodRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": false,
"conflicting_products": [],
"product": {
"customer_type": "user",
"display_name": "Test Product",
"included_items": {},
"prices": {
"monthly": {
"USD": "1000",
"interval": [
1,
"month",
],
},
},
"server_only": false,
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});