Skip to content

Commit

Permalink
feat(SaaS): Billing settings screen (#6495)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim O'Farrell <[email protected]>
  • Loading branch information
amanape and tofarr authored Feb 18, 2025
1 parent e3e00ed commit 2e98fc8
Show file tree
Hide file tree
Showing 26 changed files with 2,668 additions and 2,033 deletions.
166 changes: 166 additions & 0 deletions frontend/__tests__/components/features/payment/payment-form.test.tsx
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();
});
});
});
23 changes: 22 additions & 1 deletion frontend/__tests__/components/settings/settings-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { SettingsInput } from "#/components/features/settings/settings-input";

describe("SettingsInput", () => {
Expand Down Expand Up @@ -85,4 +86,24 @@ describe("SettingsInput", () => {

expect(screen.getByText("Start Content")).toBeInTheDocument();
});

it("should call onChange with the input value", async () => {
const onChangeMock = vi.fn();
const user = userEvent.setup();

render(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
onChange={onChangeMock}
/>,
);

const input = screen.getByTestId("test-input");
await user.type(input, "Test");

expect(onChangeMock).toHaveBeenCalledTimes(4);
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
});
});
83 changes: 83 additions & 0 deletions frontend/__tests__/routes/settings-with-payment.test.tsx
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");
});
});
2 changes: 2 additions & 0 deletions frontend/__tests__/routes/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { PostApiSettings } from "#/types/settings";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";

const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
Expand All @@ -36,6 +37,7 @@ describe("Settings Screen", () => {
{
Component: SettingsScreen,
path: "/settings",
children: [{ Component: AccountSettings, path: "/settings" }],
},
]);

Expand Down
32 changes: 32 additions & 0 deletions frontend/__tests__/utils/amount-is-valid.test.ts
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);
});
});
});
Loading

0 comments on commit 2e98fc8

Please sign in to comment.