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
42 changes: 42 additions & 0 deletions apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
releasePortfolioQueueItem,
requeuePortfolioQueueItem,
type PortfolioQueueActionItem,
type PortfolioQueueActionResult,
} from "./lib/portfolio-queue-actions";
import { PortfolioPage, PortfolioQueueActionsSection } from "./routes/portfolio";
import type { PortfolioQueueResult } from "./lib/portfolio-queue";
Expand Down Expand Up @@ -45,6 +46,7 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
render(
<PortfolioQueueActionsSection
result={null}
actionResult={null}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
Expand All @@ -57,6 +59,7 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
render(
<PortfolioQueueActionsSection
result={{ ok: false, error: "connection refused" }}
actionResult={null}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
Expand All @@ -71,6 +74,7 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
render(
<PortfolioQueueActionsSection
result={{ ok: true, items: [inProgressItem, doneItem] }}
actionResult={null}
pending={false}
onRelease={onRelease}
onRequeue={onRequeue}
Expand All @@ -86,13 +90,29 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
render(
<PortfolioQueueActionsSection
result={{ ok: true, items: [inProgressItem] }}
actionResult={null}
pending={true}
onRelease={() => undefined}
onRequeue={() => undefined}
/>,
);
expect((screen.getByRole("button", { name: "Release" }) as HTMLButtonElement).disabled).toBe(true);
});

it("renders a visible error when a queue action fails (#6090)", () => {
const failed: PortfolioQueueActionResult = { ok: false, error: "queue_entry_not_in_progress" };
render(
<PortfolioQueueActionsSection
result={{ ok: true, items: [inProgressItem] }}
actionResult={failed}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
/>,
);
expect(screen.getByRole("alert").textContent).toContain("queue_entry_not_in_progress");
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
});
});

describe("PortfolioPage queue actions (#4857)", () => {
Expand All @@ -116,6 +136,28 @@ describe("PortfolioPage queue actions (#4857)", () => {
fireEvent.click(screen.getByRole("button", { name: "Release" }));
await waitFor(() => expect(releaseItem).toHaveBeenCalledWith(inProgressItem));
});

it("REGRESSION (#6090): a failing release action renders the error and does not re-fetch items as if it succeeded", async () => {
const loadPortfolioQueueItems = vi.fn(async () => ({ ok: true as const, items: [inProgressItem] }));
const releaseItem = vi.fn(async (): Promise<PortfolioQueueActionResult> => ({
ok: false,
error: "queue_entry_not_in_progress",
}));
render(
<PortfolioPage
loadPortfolioQueue={loadPortfolioQueue}
loadPortfolioQueueItems={loadPortfolioQueueItems}
releaseItem={releaseItem}
pollIntervalMs={60_000}
/>,
);
await waitFor(() => expect(screen.getByRole("button", { name: "Release" })).toBeTruthy());
expect(loadPortfolioQueueItems).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Release" }));
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("queue_entry_not_in_progress"));
expect(releaseItem).toHaveBeenCalledWith(inProgressItem);
expect(loadPortfolioQueueItems).toHaveBeenCalledTimes(1);
});
});

describe("fetchPortfolioQueueItems / release / requeue (#4857)", () => {
Expand Down
24 changes: 19 additions & 5 deletions apps/loopover-miner-ui/src/routes/portfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
fetchPortfolioQueueItems,
requeuePortfolioQueueItem,
releasePortfolioQueueItem,
type PortfolioQueueActionItem,
type PortfolioQueueActionResult,
type PortfolioQueueItemsResult,
} from "../lib/portfolio-queue-actions";
import type { PortfolioQueueActionItem, PortfolioQueueItemsResult } from "../lib/portfolio-queue-actions";
import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue";

Expand Down Expand Up @@ -94,18 +96,25 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult |

export function PortfolioQueueActionsSection({
result,
actionResult,
pending,
onRelease,
onRequeue,
}: {
result: PortfolioQueueItemsResult | null;
actionResult: PortfolioQueueActionResult | null;
pending: boolean;
onRelease: (item: PortfolioQueueActionItem) => void;
onRequeue: (item: PortfolioQueueActionItem) => void;
}) {
return (
<section className="grid gap-3">
<h3 className="font-display text-token-base font-semibold">Queue actions</h3>
{actionResult !== null && !actionResult.ok ? (
<p role="alert" className="text-token-sm text-[var(--danger)]">
Queue action failed: {actionResult.error}
</p>
) : null}
{result === null ? (
<p className="text-token-sm text-muted-foreground">Loading actionable queue items…</p>
) : !result.ok ? (
Expand Down Expand Up @@ -168,6 +177,7 @@ export function PortfolioPage({
const [refreshKey, setRefreshKey] = useState(0);
const [actionPending, setActionPending] = useState(false);
const [itemsResult, setItemsResult] = useState<PortfolioQueueItemsResult | null>(null);
const [actionResult, setActionResult] = useState<PortfolioQueueActionResult | null>(null);

const loadSummary = useCallback(() => loadPortfolioQueue(), [loadPortfolioQueue, refreshKey]);
const summaryResult = usePolledFetch(loadSummary, pollIntervalMs);
Expand All @@ -180,11 +190,14 @@ export function PortfolioPage({
refreshItems();
}, [refreshItems]);

const runQueueAction = (action: () => Promise<unknown>) => {
const runQueueAction = (action: () => Promise<PortfolioQueueActionResult>) => {
setActionPending(true);
void action().then(() => {
setRefreshKey((key) => key + 1);
refreshItems();
void action().then((next) => {
setActionResult(next);
if (next.ok) {
setRefreshKey((key) => key + 1);
refreshItems();
}
setActionPending(false);
});
};
Expand All @@ -202,6 +215,7 @@ export function PortfolioPage({
<PortfolioQueueView result={summaryResult} />
<PortfolioQueueActionsSection
result={itemsResult}
actionResult={actionResult}
pending={actionPending}
onRelease={(item) => runQueueAction(() => releaseItem(item))}
onRequeue={(item) => runQueueAction(() => requeueItem(item))}
Expand Down
Loading