[F082] surface release-proposal query failures instead of silent hide

The card collapsed any non-404 backend failure (500 / network drop) onto
`!proposal` and returned null, so the CEO had no idea the release-proposal
endpoint was unreachable. Distinguish the cases: isError + a Retry affordance
vs the 404 null empty state that stays hidden. Mirrors PrReviewQueue.
This commit is contained in:
Renn F
2026-06-28 18:23:52 +02:00
parent 84c6f5ebe0
commit f0806cd559
2 changed files with 161 additions and 3 deletions
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { ReleaseProposal } from "@/lib/api/release";
// Control useQuery per test; the mutation + queryClient hooks just need to exist.
const { mockUseQuery } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: mockUseQuery,
useMutation: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn() })),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
})),
};
});
// releaseApi methods never run (useQuery/useMutation are mocked) — the
// component imports releaseApi from the barrel, so provide a stub object.
vi.mock("@/lib/api", () => ({
releaseApi: {
getProposal: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
},
}));
import { ReleaseProposalCard } from "../release-proposal-card";
function buildProposal(): ReleaseProposal {
return {
task_id: "t1",
title: "Cut v0.14.0",
status: "awaiting_ceo_approval",
required_changes: null,
report: {
proposed_version: "0.14.0",
bump_kind: "minor",
change_summary: ["feat: metrics"],
drafted_changelog: "## 0.14.0\n- metrics",
version_bump_plan: ["pyproject.toml"],
gaps: [],
migration_notes: [],
gate_state: "green",
},
};
}
describe("ReleaseProposalCard — query-failure surfacing (F082)", () => {
beforeEach(() => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
});
it("surfaces a backend error (not a 404) instead of silently hiding", () => {
// A non-404 failure (500, network drop) rethrows in releaseApi.getProposal,
// so useQuery sees isError=true + data=undefined. Before the fix the card
// collapsed this onto `!proposal` and returned null — the CEO had no idea
// the release-proposal endpoint was unreachable.
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("release service unavailable"),
refetch: vi.fn(),
});
render(<ReleaseProposalCard />);
// The failure must be visible — not a silent hide. The error card surfaces
// the underlying message and a retry affordance.
expect(
screen.getByText(/couldn't load the release proposal/i),
).toBeInTheDocument();
expect(
screen.getByText(/release service unavailable/i),
).toBeInTheDocument();
// A retry affordance so the CEO can re-fetch without a full page reload.
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("still hides on the 404 no-open-proposal empty state (regression guard)", () => {
// 404 → releaseApi.getProposal returns null → data=null, isError=false.
// That's the normal empty state and must stay hidden (mirrors PrReviewQueue).
mockUseQuery.mockReturnValue({
data: null,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
const { container } = render(<ReleaseProposalCard />);
expect(container).toBeEmptyDOMElement();
});
it("renders the proposal card on the happy path (regression guard)", () => {
mockUseQuery.mockReturnValue({
data: buildProposal(),
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
render(<ReleaseProposalCard />);
expect(screen.getByText(/Release Proposal/i)).toBeInTheDocument();
expect(screen.getByText("v0.14.0")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Approve & publish/i }),
).toBeInTheDocument();
});
});
@@ -44,7 +44,13 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
const [action, setAction] = useState<"approve" | "reject" | null>(null);
const [requiredChanges, setRequiredChanges] = useState("");
const { data: proposal, isLoading } = useQuery({
const {
data: proposal,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ["release", "proposal"],
queryFn: () => releaseApi.getProposal(),
refetchInterval: 30000,
@@ -102,8 +108,38 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
}
};
// Hidden entirely when there is no open proposal (mirrors PrReviewQueue).
if (isLoading || !proposal) return null;
// Loading: nothing to render yet (mirrors the prior hide).
if (isLoading) return null;
// A genuine query failure (non-404) must NOT hide silently — a 500 / network
// drop rethrows out of releaseApi.getProposal, leaving data undefined + isError
// set. Collapsing that onto `!proposal` returned null, so the CEO had no idea
// the release-proposal endpoint was unreachable. Surface it with a retry.
// (A 404 — "no open proposal" — is mapped to null in getProposal and falls
// through to the `!proposal` hide below, the normal empty state.)
if (isError) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Release Proposal
</CardTitle>
<CardDescription>
Couldn&apos;t load the release proposal
{error instanceof Error ? `: ${error.message}` : ""}.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</CardContent>
</Card>
);
}
// No open proposal (404 → null) — the normal empty state, hidden (mirrors
// PrReviewQueue).
if (!proposal) return null;
const { report } = proposal;
const pending = approveMutation.isPending || rejectMutation.isPending;