-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(SaaS): Billing settings screen (#6495)
Co-authored-by: Tim O'Farrell <[email protected]>
- Loading branch information
Showing
26 changed files
with
2,668 additions
and
2,033 deletions.
There are no files selected for viewing
166 changes: 166 additions & 0 deletions
166
frontend/__tests__/components/features/payment/payment-form.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | ||
import { render, screen, waitFor } from "@testing-library/react"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; | ||
import OpenHands from "#/api/open-hands"; | ||
import { PaymentForm } from "#/components/features/payment/payment-form"; | ||
|
||
describe("PaymentForm", () => { | ||
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance"); | ||
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession"); | ||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); | ||
|
||
const renderPaymentForm = () => | ||
render(<PaymentForm />, { | ||
wrapper: ({ children }) => ( | ||
<QueryClientProvider client={new QueryClient()}> | ||
{children} | ||
</QueryClientProvider> | ||
), | ||
}); | ||
|
||
beforeEach(() => { | ||
// useBalance hook will return the balance only if the APP_MODE is "saas" | ||
getConfigSpy.mockResolvedValue({ | ||
APP_MODE: "saas", | ||
GITHUB_CLIENT_ID: "123", | ||
POSTHOG_CLIENT_KEY: "456", | ||
}); | ||
}); | ||
|
||
afterEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
it("should render the users current balance", async () => { | ||
getBalanceSpy.mockResolvedValue("100.50"); | ||
renderPaymentForm(); | ||
|
||
await waitFor(() => { | ||
const balance = screen.getByTestId("user-balance"); | ||
expect(balance).toHaveTextContent("$100.50"); | ||
}); | ||
}); | ||
|
||
it("should render the users current balance to two decimal places", async () => { | ||
getBalanceSpy.mockResolvedValue("100"); | ||
renderPaymentForm(); | ||
|
||
await waitFor(() => { | ||
const balance = screen.getByTestId("user-balance"); | ||
expect(balance).toHaveTextContent("$100.00"); | ||
}); | ||
}); | ||
|
||
test("the user can top-up a specific amount", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "50.12"); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12); | ||
}); | ||
|
||
it("should round the top-up amount to two decimal places", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "50.125456"); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13); | ||
}); | ||
|
||
it("should render the payment method link", async () => { | ||
renderPaymentForm(); | ||
|
||
screen.getByTestId("payment-methods-link"); | ||
}); | ||
|
||
it("should disable the top-up button if the user enters an invalid amount", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
expect(topUpButton).toBeDisabled(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, " "); | ||
|
||
expect(topUpButton).toBeDisabled(); | ||
}); | ||
|
||
it("should disable the top-up button after submission", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "50.12"); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(topUpButton).toBeDisabled(); | ||
}); | ||
|
||
describe("prevent submission if", () => { | ||
test("user enters a negative amount", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "-50.12"); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); | ||
}); | ||
|
||
test("user enters an empty string", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, " "); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); | ||
}); | ||
|
||
test("user enters a non-numeric value", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "abc"); | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); | ||
}); | ||
|
||
test("user enters less than the minimum amount", async () => { | ||
const user = userEvent.setup(); | ||
renderPaymentForm(); | ||
|
||
const topUpInput = await screen.findByTestId("top-up-input"); | ||
await user.type(topUpInput, "20"); // test assumes the minimum is 25 | ||
|
||
const topUpButton = screen.getByText("Add credit"); | ||
await user.click(topUpButton); | ||
|
||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
import { screen, waitFor, within } from "@testing-library/react"; | ||
import userEvent from "@testing-library/user-event"; | ||
import { afterEach, describe, expect, it, vi } from "vitest"; | ||
import { createRoutesStub } from "react-router"; | ||
import { renderWithProviders } from "test-utils"; | ||
import OpenHands from "#/api/open-hands"; | ||
import SettingsScreen from "#/routes/settings"; | ||
import { PaymentForm } from "#/components/features/payment/payment-form"; | ||
import * as FeatureFlags from "#/utils/feature-flags"; | ||
|
||
describe("Settings Billing", () => { | ||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); | ||
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true); | ||
|
||
const RoutesStub = createRoutesStub([ | ||
{ | ||
Component: SettingsScreen, | ||
path: "/settings", | ||
children: [ | ||
{ | ||
Component: () => <PaymentForm />, | ||
path: "/settings/billing", | ||
}, | ||
], | ||
}, | ||
]); | ||
|
||
const renderSettingsScreen = () => | ||
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />); | ||
|
||
afterEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
it("should not render the navbar if OSS mode", async () => { | ||
getConfigSpy.mockResolvedValue({ | ||
APP_MODE: "oss", | ||
GITHUB_CLIENT_ID: "123", | ||
POSTHOG_CLIENT_KEY: "456", | ||
}); | ||
|
||
renderSettingsScreen(); | ||
|
||
await waitFor(() => { | ||
const navbar = screen.queryByTestId("settings-navbar"); | ||
expect(navbar).not.toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it("should render the navbar if SaaS mode", async () => { | ||
getConfigSpy.mockResolvedValue({ | ||
APP_MODE: "saas", | ||
GITHUB_CLIENT_ID: "123", | ||
POSTHOG_CLIENT_KEY: "456", | ||
}); | ||
|
||
renderSettingsScreen(); | ||
|
||
await waitFor(() => { | ||
const navbar = screen.getByTestId("settings-navbar"); | ||
within(navbar).getByText("Account"); | ||
within(navbar).getByText("Credits"); | ||
}); | ||
}); | ||
|
||
it("should render the billing settings if clicking the credits item", async () => { | ||
const user = userEvent.setup(); | ||
getConfigSpy.mockResolvedValue({ | ||
APP_MODE: "saas", | ||
GITHUB_CLIENT_ID: "123", | ||
POSTHOG_CLIENT_KEY: "456", | ||
}); | ||
|
||
renderSettingsScreen(); | ||
|
||
const navbar = await screen.findByTestId("settings-navbar"); | ||
const credits = within(navbar).getByText("Credits"); | ||
await user.click(credits); | ||
|
||
const billingSection = await screen.findByTestId("billing-settings"); | ||
within(billingSection).getByText("Manage Credits"); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { describe, expect, test } from "vitest"; | ||
import { amountIsValid } from "#/utils/amount-is-valid"; | ||
|
||
describe("amountIsValid", () => { | ||
describe("fails", () => { | ||
test("when the amount is negative", () => { | ||
expect(amountIsValid("-5")).toBe(false); | ||
expect(amountIsValid("-25")).toBe(false); | ||
}); | ||
|
||
test("when the amount is zero", () => { | ||
expect(amountIsValid("0")).toBe(false); | ||
}); | ||
|
||
test("when an empty string is passed", () => { | ||
expect(amountIsValid("")).toBe(false); | ||
expect(amountIsValid(" ")).toBe(false); | ||
}); | ||
|
||
test("when a non-numeric value is passed", () => { | ||
expect(amountIsValid("abc")).toBe(false); | ||
expect(amountIsValid("1abc")).toBe(false); | ||
expect(amountIsValid("abc1")).toBe(false); | ||
}); | ||
|
||
test("when an amount less than the minimum is passed", () => { | ||
// test assumes the minimum is 25 | ||
expect(amountIsValid("24")).toBe(false); | ||
expect(amountIsValid("24.99")).toBe(false); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.