mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
refactor(frontend): extract AddCreditsModal into separate component file (#13490)
This commit is contained in:
@@ -0,0 +1,351 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithProviders } from "test-utils";
|
||||||
|
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
|
||||||
|
import BillingService from "#/api/billing-service/billing-service.api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: {
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AddCreditsModal", () => {
|
||||||
|
const onCloseMock = vi.fn();
|
||||||
|
|
||||||
|
const renderModal = () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<AddCreditsModal onClose={onCloseMock} />);
|
||||||
|
return { user };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rendering", () => {
|
||||||
|
it("should render the form with correct elements", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("add-credits-form")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("amount-input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display the title", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Button State Management", () => {
|
||||||
|
it("should enable submit button initially when modal opens", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable submit button when input contains invalid value", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "-50");
|
||||||
|
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable submit button when input contains valid value", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "100");
|
||||||
|
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable submit button after validation error is shown", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "9");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Input Attributes & Placeholder", () => {
|
||||||
|
it("should have min attribute set to 10", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
expect(amountInput).toHaveAttribute("min", "10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have max attribute set to 25000", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
expect(amountInput).toHaveAttribute("max", "25000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have step attribute set to 1", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
expect(amountInput).toHaveAttribute("step", "1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Message Display", () => {
|
||||||
|
it("should not display error message initially when modal opens", () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const errorMessage = screen.queryByTestId("amount-error");
|
||||||
|
expect(errorMessage).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error message after submitting amount above maximum", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "25001");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error message after submitting decimal value", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "50.5");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error message after submitting amount below minimum", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "9");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display error message after submitting negative amount", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "-50");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should replace error message when submitting different invalid value", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "9");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.clear(amountInput);
|
||||||
|
await user.type(amountInput, "25001");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Form Submission Behavior", () => {
|
||||||
|
it("should prevent submission when amount is invalid", async () => {
|
||||||
|
const createCheckoutSessionSpy = vi.spyOn(
|
||||||
|
BillingService,
|
||||||
|
"createCheckoutSession",
|
||||||
|
);
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "9");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||||
|
const createCheckoutSessionSpy = vi.spyOn(
|
||||||
|
BillingService,
|
||||||
|
"createCheckoutSession",
|
||||||
|
);
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "1000");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||||
|
const errorMessage = screen.queryByTestId("amount-error");
|
||||||
|
expect(errorMessage).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call createCheckoutSession when validation fails", async () => {
|
||||||
|
const createCheckoutSessionSpy = vi.spyOn(
|
||||||
|
BillingService,
|
||||||
|
"createCheckoutSession",
|
||||||
|
);
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "-50");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close modal on successful submission", async () => {
|
||||||
|
vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue(
|
||||||
|
"https://checkout.stripe.com/test-session",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "1000");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onCloseMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||||
|
const createCheckoutSessionSpy = vi.spyOn(
|
||||||
|
BillingService,
|
||||||
|
"createCheckoutSession",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
// First submit invalid value
|
||||||
|
await user.type(amountInput, "9");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then submit valid value
|
||||||
|
await user.clear(amountInput);
|
||||||
|
await user.type(amountInput, "100");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||||
|
const errorMessage = screen.queryByTestId("amount-error");
|
||||||
|
expect(errorMessage).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases", () => {
|
||||||
|
it("should handle zero value correctly", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
await user.type(amountInput, "0");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMessage = screen.getByTestId("amount-error");
|
||||||
|
expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only input correctly", async () => {
|
||||||
|
const createCheckoutSessionSpy = vi.spyOn(
|
||||||
|
BillingService,
|
||||||
|
"createCheckoutSession",
|
||||||
|
);
|
||||||
|
const { user } = renderModal();
|
||||||
|
const amountInput = screen.getByTestId("amount-input");
|
||||||
|
const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i });
|
||||||
|
|
||||||
|
// Number inputs typically don't accept spaces, but test the behavior
|
||||||
|
await user.type(amountInput, " ");
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
// Should not call API (empty/invalid input)
|
||||||
|
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Modal Interaction", () => {
|
||||||
|
it("should call onClose when cancel button is clicked", async () => {
|
||||||
|
const { user } = renderModal();
|
||||||
|
|
||||||
|
const cancelButton = screen.getByRole("button", { name: /close/i });
|
||||||
|
await user.click(cancelButton);
|
||||||
|
|
||||||
|
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -283,305 +283,6 @@ describe("Manage Org Route", () => {
|
|||||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddCreditsModal", () => {
|
|
||||||
const openAddCreditsModal = async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderManageOrg();
|
|
||||||
await screen.findByTestId("manage-org-screen");
|
|
||||||
|
|
||||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
|
||||||
|
|
||||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
|
||||||
await user.click(addCreditsButton);
|
|
||||||
|
|
||||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
|
||||||
expect(addCreditsForm).toBeInTheDocument();
|
|
||||||
|
|
||||||
return { user, addCreditsForm };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Button State Management", () => {
|
|
||||||
it("should enable submit button initially when modal opens", async () => {
|
|
||||||
await openAddCreditsModal();
|
|
||||||
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
expect(nextButton).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable submit button when input contains invalid value", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "-50");
|
|
||||||
|
|
||||||
expect(nextButton).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable submit button when input contains valid value", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "100");
|
|
||||||
|
|
||||||
expect(nextButton).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable submit button after validation error is shown", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "9");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(nextButton).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Input Attributes & Placeholder", () => {
|
|
||||||
it("should have min attribute set to 10", async () => {
|
|
||||||
await openAddCreditsModal();
|
|
||||||
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
expect(amountInput).toHaveAttribute("min", "10");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have max attribute set to 25000", async () => {
|
|
||||||
await openAddCreditsModal();
|
|
||||||
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
expect(amountInput).toHaveAttribute("max", "25000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have step attribute set to 1", async () => {
|
|
||||||
await openAddCreditsModal();
|
|
||||||
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
expect(amountInput).toHaveAttribute("step", "1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Message Display", () => {
|
|
||||||
it("should not display error message initially when modal opens", async () => {
|
|
||||||
await openAddCreditsModal();
|
|
||||||
|
|
||||||
const errorMessage = screen.queryByTestId("amount-error");
|
|
||||||
expect(errorMessage).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display error message after submitting amount above maximum", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "25001");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display error message after submitting decimal value", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "50.5");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should replace error message when submitting different invalid value", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "9");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.clear(amountInput);
|
|
||||||
await user.type(amountInput, "25001");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Form Submission Behavior", () => {
|
|
||||||
it("should prevent submission when amount is invalid", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi.spyOn(
|
|
||||||
BillingService,
|
|
||||||
"createCheckoutSession",
|
|
||||||
);
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "9");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi.spyOn(
|
|
||||||
BillingService,
|
|
||||||
"createCheckoutSession",
|
|
||||||
);
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "1000");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
|
||||||
const errorMessage = screen.queryByTestId("amount-error");
|
|
||||||
expect(errorMessage).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call createCheckoutSession when validation fails", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi.spyOn(
|
|
||||||
BillingService,
|
|
||||||
"createCheckoutSession",
|
|
||||||
);
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "-50");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
// Verify mutation was not called
|
|
||||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_NEGATIVE_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should close modal on successful submission", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi
|
|
||||||
.spyOn(BillingService, "createCheckoutSession")
|
|
||||||
.mockResolvedValue("https://checkout.stripe.com/test-session");
|
|
||||||
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "1000");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.queryByTestId("add-credits-form"),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi.spyOn(
|
|
||||||
BillingService,
|
|
||||||
"createCheckoutSession",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
// First submit invalid value
|
|
||||||
await user.type(amountInput, "9");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then submit valid value
|
|
||||||
await user.clear(amountInput);
|
|
||||||
await user.type(amountInput, "100");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
|
||||||
const errorMessage = screen.queryByTestId("amount-error");
|
|
||||||
expect(errorMessage).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
it("should handle zero value correctly", async () => {
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
await user.type(amountInput, "0");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const errorMessage = screen.getByTestId("amount-error");
|
|
||||||
expect(errorMessage).toHaveTextContent(
|
|
||||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle whitespace-only input correctly", async () => {
|
|
||||||
const createCheckoutSessionSpy = vi.spyOn(
|
|
||||||
BillingService,
|
|
||||||
"createCheckoutSession",
|
|
||||||
);
|
|
||||||
const { user } = await openAddCreditsModal();
|
|
||||||
const amountInput = screen.getByTestId("amount-input");
|
|
||||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
|
||||||
|
|
||||||
// Number inputs typically don't accept spaces, but test the behavior
|
|
||||||
await user.type(amountInput, " ");
|
|
||||||
await user.click(nextButton);
|
|
||||||
|
|
||||||
// Should not call API (empty/invalid input)
|
|
||||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show add credits option for ADMIN role", async () => {
|
it("should show add credits option for ADMIN role", async () => {
|
||||||
renderManageOrg();
|
renderManageOrg();
|
||||||
await screen.findByTestId("manage-org-screen");
|
await screen.findByTestId("manage-org-screen");
|
||||||
|
|||||||
103
frontend/src/components/features/org/add-credits-modal.tsx
Normal file
103
frontend/src/components/features/org/add-credits-modal.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||||
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
|
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
|
||||||
|
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||||
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||||
|
|
||||||
|
interface AddCreditsModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCreditsModal({ onClose }: AddCreditsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate: addBalance } = useCreateStripeCheckoutSession();
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const getErrorMessage = (value: string): string | null => {
|
||||||
|
if (!value.trim()) return null;
|
||||||
|
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(numValue)) {
|
||||||
|
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
|
||||||
|
}
|
||||||
|
if (numValue < 0) {
|
||||||
|
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
|
||||||
|
}
|
||||||
|
if (numValue < 10) {
|
||||||
|
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
|
||||||
|
}
|
||||||
|
if (numValue > 25000) {
|
||||||
|
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
|
||||||
|
}
|
||||||
|
if (numValue !== parseFloat(value)) {
|
||||||
|
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAction = (formData: FormData) => {
|
||||||
|
const amount = formData.get("amount")?.toString();
|
||||||
|
|
||||||
|
if (amount?.trim()) {
|
||||||
|
if (!amountIsValid(amount)) {
|
||||||
|
const error = getErrorMessage(amount);
|
||||||
|
setErrorMessage(error || "Invalid amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intValue = parseInt(amount, 10);
|
||||||
|
|
||||||
|
addBalance({ amount: intValue }, { onSuccess: onClose });
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAmountInputChange = (value: string) => {
|
||||||
|
setInputValue(value);
|
||||||
|
setErrorMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<form
|
||||||
|
data-testid="add-credits-form"
|
||||||
|
action={formAction}
|
||||||
|
noValidate
|
||||||
|
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<SettingsInput
|
||||||
|
testId="amount-input"
|
||||||
|
name="amount"
|
||||||
|
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={25000}
|
||||||
|
step={1}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(value) => handleAmountInputChange(value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalButtonGroup
|
||||||
|
primaryText={t(I18nKey.ORG$NEXT)}
|
||||||
|
onSecondaryClick={onClose}
|
||||||
|
primaryType="submit"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
|
||||||
import { useOrganization } from "#/hooks/query/use-organization";
|
import { useOrganization } from "#/hooks/query/use-organization";
|
||||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
|
||||||
import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group";
|
|
||||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
|
||||||
import { useMe } from "#/hooks/query/use-me";
|
import { useMe } from "#/hooks/query/use-me";
|
||||||
import { useConfig } from "#/hooks/query/use-config";
|
import { useConfig } from "#/hooks/query/use-config";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
|
||||||
import { CreditsChip } from "#/ui/credits-chip";
|
import { CreditsChip } from "#/ui/credits-chip";
|
||||||
import { InteractiveChip } from "#/ui/interactive-chip";
|
import { InteractiveChip } from "#/ui/interactive-chip";
|
||||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||||
@@ -16,104 +11,10 @@ import { createPermissionGuard } from "#/utils/org/permission-guard";
|
|||||||
import { isBillingHidden } from "#/utils/org/billing-visibility";
|
import { isBillingHidden } from "#/utils/org/billing-visibility";
|
||||||
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
|
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
|
||||||
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
|
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
|
||||||
|
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
|
||||||
import { useBalance } from "#/hooks/query/use-balance";
|
import { useBalance } from "#/hooks/query/use-balance";
|
||||||
import { cn } from "#/utils/utils";
|
import { cn } from "#/utils/utils";
|
||||||
|
|
||||||
interface AddCreditsModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddCreditsModal({ onClose }: AddCreditsModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { mutate: addBalance } = useCreateStripeCheckoutSession();
|
|
||||||
|
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
|
||||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const getErrorMessage = (value: string): string | null => {
|
|
||||||
if (!value.trim()) return null;
|
|
||||||
|
|
||||||
const numValue = parseInt(value, 10);
|
|
||||||
if (Number.isNaN(numValue)) {
|
|
||||||
return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER);
|
|
||||||
}
|
|
||||||
if (numValue < 0) {
|
|
||||||
return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT);
|
|
||||||
}
|
|
||||||
if (numValue < 10) {
|
|
||||||
return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT);
|
|
||||||
}
|
|
||||||
if (numValue > 25000) {
|
|
||||||
return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT);
|
|
||||||
}
|
|
||||||
if (numValue !== parseFloat(value)) {
|
|
||||||
return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formAction = (formData: FormData) => {
|
|
||||||
const amount = formData.get("amount")?.toString();
|
|
||||||
|
|
||||||
if (amount?.trim()) {
|
|
||||||
if (!amountIsValid(amount)) {
|
|
||||||
const error = getErrorMessage(amount);
|
|
||||||
setErrorMessage(error || "Invalid amount");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intValue = parseInt(amount, 10);
|
|
||||||
|
|
||||||
addBalance({ amount: intValue }, { onSuccess: onClose });
|
|
||||||
|
|
||||||
setErrorMessage(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAmountInputChange = (value: string) => {
|
|
||||||
setInputValue(value);
|
|
||||||
setErrorMessage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBackdrop onClose={onClose}>
|
|
||||||
<form
|
|
||||||
data-testid="add-credits-form"
|
|
||||||
action={formAction}
|
|
||||||
noValidate
|
|
||||||
className="w-sm rounded-xl bg-base-secondary flex flex-col p-6 gap-4 border border-tertiary"
|
|
||||||
>
|
|
||||||
<h3 className="text-xl font-bold">{t(I18nKey.ORG$ADD_CREDITS)}</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<SettingsInput
|
|
||||||
testId="amount-input"
|
|
||||||
name="amount"
|
|
||||||
label={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)}
|
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={25000}
|
|
||||||
step={1}
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(value) => handleAmountInputChange(value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
{errorMessage && (
|
|
||||||
<p className="text-red-500 text-sm mt-1" data-testid="amount-error">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModalButtonGroup
|
|
||||||
primaryText={t(I18nKey.ORG$NEXT)}
|
|
||||||
onSecondaryClick={onClose}
|
|
||||||
primaryType="submit"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</ModalBackdrop>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clientLoader = createPermissionGuard("view_billing");
|
export const clientLoader = createPermissionGuard("view_billing");
|
||||||
|
|
||||||
function ManageOrg() {
|
function ManageOrg() {
|
||||||
|
|||||||
Reference in New Issue
Block a user