From f88a7c7c4f67ad2201ae07f1b9804863892228f4 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 10:59:46 -0600 Subject: [PATCH] feat(acks): surface 999 rejections via sidebar badge + table sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1,156 accepted acks were burying the 5 rejections — the operator had to scroll the whole table to find them. Two changes: 1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing the total rejected segment count from aggregates.rejected_count (mirrors the Reconciliation unmatched badge pattern). One number, visible from any page. 2. Acks page table now sorts rejected rows to the top, then newest-id-first. All 5 rejections fit on page 1 since they're a tiny fraction of the total — no pagination needed. New files: - src/hooks/useAckStats.ts (lightweight aggregates-only fetch) - src/hooks/useAckStats.test.tsx (2 tests) - src/components/Sidebar.test.tsx (3 tests) Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837, tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy). Unchanged. --- src/components/Sidebar.test.tsx | 96 +++++++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 22 ++++++++ src/hooks/useAckStats.test.tsx | 51 ++++++++++++++++++ src/hooks/useAckStats.ts | 29 ++++++++++ src/pages/Acks.test.tsx | 63 ++++++++++++++++++++++ src/pages/Acks.tsx | 18 ++++++- 6 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/components/Sidebar.test.tsx create mode 100644 src/hooks/useAckStats.test.tsx create mode 100644 src/hooks/useAckStats.ts diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx new file mode 100644 index 0000000..184739f --- /dev/null +++ b/src/components/Sidebar.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, waitFor, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Stub the auth hook so Sidebar doesn't try to read a token. +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ user: { id: 1, username: "admin", role: "admin" }, logout: vi.fn() }), +})); + +// Mutable state so each test can flip the rejected-acks count without +// re-importing the module. `vi.hoisted` ensures the mock factory closes +// over the same `state` object across calls. +const state = vi.hoisted(() => ({ + unmatchedClaims: 0, + unmatchedRemits: 0, + rejectedAcks: 0, +})); + +vi.mock("@/hooks/useReconciliation", () => ({ + useReconciliation: () => ({ + unmatched: { + data: { + claims: Array.from({ length: state.unmatchedClaims }), + remittances: Array.from({ length: state.unmatchedRemits }), + }, + }, + }), +})); + +vi.mock("@/hooks/useAckStats", () => ({ + useAckStats: () => ({ + data: state.rejectedAcks > 0 + ? { aggregates: { accepted: 1000, rejected: state.rejectedAcks, received: 1005 } } + : undefined, + }), +})); + +import { Sidebar } from "./Sidebar"; + +function renderSidebar() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +describe("Sidebar — 999 ACKs rejected badge", () => { + beforeEach(() => { + cleanup(); + state.unmatchedClaims = 0; + state.unmatchedRemits = 0; + state.rejectedAcks = 0; + }); + + it("shows a '5' badge when 5 segments are rejected", async () => { + state.rejectedAcks = 5; + const { container } = renderSidebar(); + await waitFor(() => { + const badge = container.querySelector( + '[data-testid="sidebar-acks-rejected-badge"]', + ); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe("5"); + }); + }); + + it("shows '99+' when rejected segments exceed 99", async () => { + state.rejectedAcks = 250; + const { container } = renderSidebar(); + await waitFor(() => { + const badge = container.querySelector( + '[data-testid="sidebar-acks-rejected-badge"]', + ); + expect(badge?.textContent).toBe("99+"); + }); + }); + + it("hides the badge when there are no rejected acks", async () => { + state.rejectedAcks = 0; + const { container } = renderSidebar(); + // Allow the synchronous mock to settle. + await waitFor(() => { + expect( + container.querySelector('[data-testid="sidebar-acks-rejected-badge"]'), + ).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7d4be7c..fbcc07d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { useReconciliation } from "@/hooks/useReconciliation"; +import { useAckStats } from "@/hooks/useAckStats"; import { useAuth } from "@/auth/useAuth"; const nav = [ @@ -43,6 +44,13 @@ export function Sidebar() { (unmatched.data?.claims.length ?? 0) + (unmatched.data?.remittances.length ?? 0); + // 999 ACKs — total rejected segments across all ack files. Drives + // the badge on the "999 ACKs" nav item so the operator can see + // there are rejections to review from any page (mirrors the + // Reconciliation unmatched badge above). + const { data: ackStats } = useAckStats(); + const rejectedAckCount = ackStats?.aggregates.rejected ?? 0; + return (