Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions web/src/CloudAccountPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

import {
cancelPersonalBilling,
Expand Down Expand Up @@ -26,6 +26,9 @@ function date(epoch: number | null): string | null { return epoch === null ? nul

export function CloudAccountPanel() {
const [eligibility, setEligibility] = useState<EligibilityView | null>(null);
const [eligibilityLoading, setEligibilityLoading] = useState(true);
const [eligibilityAttempted, setEligibilityAttempted] = useState(false);
const eligibilityRequestInFlight = useRef(false);
const [offer, setOffer] = useState<PersonalBillingTerm>("monthly");
const [quote, setQuote] = useState<PersonalQuote | null>(null);
const [lifecycle, setLifecycle] = useState<PersonalLifecycle | null>(null);
Expand All @@ -37,11 +40,19 @@ export function CloudAccountPanel() {
const [notice, setNotice] = useState<string | null>(null);

async function load() {
if (eligibilityRequestInFlight.current) return;
eligibilityRequestInFlight.current = true;
setEligibilityLoading(true);
setError(null);
try {
const next = await fetchEligibility();
setEligibility(next);
} catch (e) { setError(message(e)); }
finally {
eligibilityRequestInFlight.current = false;
setEligibilityAttempted(true);
setEligibilityLoading(false);
}
}
useEffect(() => { void load(); }, []);
useEffect(() => {
Expand Down Expand Up @@ -127,7 +138,12 @@ export function CloudAccountPanel() {
finally { setBusy(false); }
}

if (eligibility === null) return <><h1>Cloud account</h1>{error !== null ? <p role="alert">{error}</p> : <p className="muted">Loading account status…</p>}</>;
if (eligibility === null) return <>
<h1>Cloud account</h1>
{error !== null && <p role="alert">{error}</p>}
{eligibilityLoading && <p className="muted" role="status">{eligibilityAttempted ? "Checking account status again…" : "Loading account status…"}</p>}
{eligibilityAttempted && <button disabled={eligibilityLoading} aria-busy={eligibilityLoading} onClick={() => void load()}>{eligibilityLoading ? "Retrying…" : "Retry account status"}</button>}
</>;
const paidThrough = date(eligibility.paidThroughEpoch); const recoveryUntil = date(eligibility.recoveryUntilEpoch); const exportUntil = date(eligibility.exportUntilEpoch);
return <>
<h1>Cloud account</h1>
Expand Down
73 changes: 72 additions & 1 deletion web/src/__tests__/CloudAccountPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { StrictMode } from "react";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import * as api from "../api";
import type { EligibilityView } from "../api";
import { CloudAccountPanel } from "../CloudAccountPanel";

vi.mock("../api", () => ({
Expand Down Expand Up @@ -39,6 +41,19 @@ describe("CloudAccountPanel", () => {

beforeEach(() => vi.mocked(api.fetchCloudNotices).mockResolvedValue([]));

it("requests initial eligibility only once during StrictMode effect replay", async () => {
vi.mocked(api.fetchEligibility).mockResolvedValue({ ...base, state: "unavailable" });

render(
<StrictMode>
<CloudAccountPanel />
</StrictMode>,
);

expect(await screen.findByText(/temporarily unavailable/)).toBeInTheDocument();
expect(api.fetchEligibility).toHaveBeenCalledTimes(1);
});

it("does not offer checkout while eligibility evidence is unavailable", async () => {
vi.mocked(api.fetchEligibility).mockResolvedValue({ ...base, state: "unavailable" });

Expand All @@ -49,6 +64,62 @@ describe("CloudAccountPanel", () => {
expect(api.fetchPersonalQuote).not.toHaveBeenCalled();
});

it("offers a retry when the initial eligibility request fails", async () => {
vi.mocked(api.fetchEligibility).mockRejectedValueOnce(new Error("synthetic status failure"));

render(<CloudAccountPanel />);

expect(await screen.findByRole("alert")).toHaveTextContent("synthetic status failure");
expect(screen.getByRole("button", { name: "Retry account status" })).toBeInTheDocument();
expect(api.fetchPersonalQuote).not.toHaveBeenCalled();
});

it("clears the error while retrying, prevents overlap, and shows eligible controls on success", async () => {
let resolveRetry!: (eligibility: EligibilityView) => void;
vi.mocked(api.fetchEligibility)
.mockRejectedValueOnce(new Error("synthetic status failure"))
.mockImplementationOnce(() => new Promise((resolve) => { resolveRetry = resolve; }));
vi.mocked(api.fetchPersonalQuote).mockResolvedValue({
offer: "standard_monthly", amountPence: 299, currency: "gbp", interval: "month", taxTreatment: "shown_at_checkout",
quoteVersion: 1, quoteExpiresAtEpoch: 2_000_000_000, founding: false,
foundingRemainingPlaces: null, foundingTerm: null, nextRenewalAmountPence: 499,
});

render(<CloudAccountPanel />);

expect(await screen.findByRole("alert")).toHaveTextContent("synthetic status failure");
fireEvent.click(screen.getByRole("button", { name: "Retry account status" }));

expect(await screen.findByRole("status")).toHaveTextContent("Checking account status again");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
const retrying = screen.getByRole("button", { name: "Retrying…" });
expect(retrying).toBeDisabled();
expect(api.fetchEligibility).toHaveBeenCalledTimes(2);
fireEvent.click(retrying);
expect(api.fetchEligibility).toHaveBeenCalledTimes(2);

resolveRetry({ ...base, state: "free", actions: { ...base.actions, billing: true } });

expect(await screen.findByRole("button", { name: /secure checkout/i })).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(api.fetchEligibility).toHaveBeenCalledTimes(2);
});

it("restores the retry control after a retry also fails", async () => {
vi.mocked(api.fetchEligibility)
.mockRejectedValueOnce(new Error("first status failure"))
.mockRejectedValueOnce(new Error("second status failure"));

render(<CloudAccountPanel />);

expect(await screen.findByRole("alert")).toHaveTextContent("first status failure");
fireEvent.click(screen.getByRole("button", { name: "Retry account status" }));

expect(await screen.findByRole("alert")).toHaveTextContent("second status failure");
expect(screen.getByRole("button", { name: "Retry account status" })).toBeEnabled();
expect(api.fetchPersonalQuote).not.toHaveBeenCalled();
});

it("shows the server quote before offering a personal checkout", async () => {
vi.mocked(api.fetchEligibility).mockResolvedValue({ ...base, state: "free", actions: { ...base.actions, billing: true } });
vi.mocked(api.fetchPersonalQuote).mockResolvedValue({
Expand Down
Loading