mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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 [action, setAction] = useState<"approve" | "reject" | null>(null);
|
||||||
const [requiredChanges, setRequiredChanges] = useState("");
|
const [requiredChanges, setRequiredChanges] = useState("");
|
||||||
|
|
||||||
const { data: proposal, isLoading } = useQuery({
|
const {
|
||||||
|
data: proposal,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ["release", "proposal"],
|
queryKey: ["release", "proposal"],
|
||||||
queryFn: () => releaseApi.getProposal(),
|
queryFn: () => releaseApi.getProposal(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
@@ -102,8 +108,38 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hidden entirely when there is no open proposal (mirrors PrReviewQueue).
|
// Loading: nothing to render yet (mirrors the prior hide).
|
||||||
if (isLoading || !proposal) return null;
|
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'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 { report } = proposal;
|
||||||
const pending = approveMutation.isPending || rejectMutation.isPending;
|
const pending = approveMutation.isPending || rejectMutation.isPending;
|
||||||
|
|||||||
Reference in New Issue
Block a user