feat(sp28): useClaimAcks + useAckClaims hooks with api types

This commit is contained in:
Nora
2026-07-02 11:26:25 -06:00
parent 9e775c0150
commit d2512945ca
6 changed files with 770 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
// React Query's internal state updates need an `act`-aware environment
// or it logs noisy warnings. Mirror the useClaimDetail /
// useClaimAcks / useTailStream test convention.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAckClaims } from "./useAckClaims";
import { api } from "@/lib/api";
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
// single `api.listAckClaims` method the hook calls.
vi.mock("@/lib/api", () => ({
api: {
listAckClaims: vi.fn(),
},
}));
/** Minimal `renderHook` shim — same pattern as `useClaimAcks.test.ts`. */
function renderHook<TResult>(
useCallback: () => TResult
): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Probe() {
result.current = useCallback();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
);
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/** Poll the result until `predicate` is true or we time out. */
async function waitForState(
predicate: () => boolean,
timeoutMs = 1000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForState: predicate did not hold within ${timeoutMs}ms`,
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/** Sample fixture — one 999 with two AK2 set-responses, both auto-linked
* to the same claim. Mirrors the camelCase `ClaimAck` shape (the api
* layer's `mapClaimAck` does the snake_case→camelCase conversion). */
const SAMPLE_ROWS = [
{
id: 11,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
linkedBy: "auto" as const,
claimState: "received",
},
{
id: 12,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 1,
setControlNumber: "991102990",
setAcceptRejectCode: "R",
linkedAt: "2026-07-02T12:00:01Z",
linkedBy: "auto" as const,
claimState: "received",
},
];
describe("useAckClaims", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("test_returns_idle_state_and_does_not_fetch_when_ackId_is_null", () => {
// Drawer closed → no network call, no loading, no error. The hook
// must short-circuit BEFORE the queryFn is ever invoked so a
// closed drawer is free.
const { result, unmount } = renderHook(() => useAckClaims("999", null));
expect(result.current).toBeDefined();
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
expect(result.current?.error).toBeNull();
expect(typeof result.current?.refetch).toBe("function");
expect(api.listAckClaims).not.toHaveBeenCalled();
unmount();
});
it("test_returns_idle_state_and_does_not_fetch_when_kind_is_null", () => {
// Defensive — the hook accepts `kind: ClaimAckKind | null` and
// short-circuits when it's null (mirrors the ackId null check).
const { result, unmount } = renderHook(() => useAckClaims(null, 42));
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(api.listAckClaims).not.toHaveBeenCalled();
unmount();
});
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_ack", async () => {
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
SAMPLE_ROWS,
);
const { result, unmount } = renderHook(() => useAckClaims("999", 99));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// The hook must have invoked the API exactly once with the kind+id.
expect(api.listAckClaims).toHaveBeenCalledTimes(1);
expect(api.listAckClaims).toHaveBeenCalledWith("999", 99);
const data = result.current?.data;
expect(data).not.toBeNull();
expect(data).toHaveLength(2);
// Both rows belong to the same ack (per-AK2 granularity for 999).
expect(data?.[0]?.ackId).toBe(99);
expect(data?.[0]?.ackKind).toBe("999");
expect(data?.[0]?.ak2Index).toBe(0);
expect(data?.[1]?.ak2Index).toBe(1);
// isLoading must flip false once the fetch resolves.
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
unmount();
});
it("test_returns_empty_array_when_ack_has_no_claim_links", async () => {
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
const { result, unmount } = renderHook(() => useAckClaims("999", 77));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// Empty list must be surfaced as [] (NOT null), so the AckDrawer
// panel can render its "Link to claim…" dropdown without a null
// check.
expect(result.current?.data).toEqual([]);
unmount();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ClaimAck, ClaimAckKind } from "@/types";
/**
* Per-ack claims query (AckDrawer · SP28 §5.2).
*
* Lists every `claim_acks` row for the given ack. For 999/277CA,
* one entry per AK2 / ClaimStatus (D1 per-AK2 granularity). For
* TA1, one entry per envelope linked to the originating `Batch`.
* Drives the new `<MatchedClaim />` panel inside `AckDrawer`.
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `ackId === null` (drawer closed): the query is disabled and the
* hook short-circuits to the empty drawer state `{ data: null, ... }`
* so a closed drawer doesn't burn a network request or a TanStack
* cache slot. The `AckDrawer` panel guards the `data ?? []` access
* on this and renders the "Link to claim…" dropdown when the list
* is empty.
* - `kind` and `ackId` set: fetches
* `GET /api/acks/{kind}/{ack_id}/claims` via `api.listAckClaims`.
* Cached 30 s — the link rows don't change once created (only
* manual-match / unlink mutate them, and those fire
* `claim_ack_written` / `claim_ack_dropped` events that the
* live-tail subscription delivers via `useTailStream("claim-acks")`).
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The live-tail subscription (`useTailStream("claim-acks")` +
* `useMergedTail("claim-acks", data ?? [], (link) => link.ackId === ackId)`)
* is mounted by the `AckDrawer` itself (per
* `cyclone-frontend-page` convention #3 — subscription on the page,
* not in the hook).
*/
export function useAckClaims(
kind: ClaimAckKind | null,
ackId: number | null,
): {
data: ClaimAck[] | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<ClaimAck[]>({
queryKey: ["ack-claims", kind, ackId],
queryFn: () => api.listAckClaims(kind as ClaimAckKind, ackId as number),
enabled: kind !== null && ackId !== null,
staleTime: 30 * 1000,
retry: (failureCount, error) => {
// Don't retry 404 — same posture as the other detail hooks.
if (error instanceof Error && "status" in error) {
const status = (error as { status?: unknown }).status;
if (status === 404) return false;
}
return failureCount < 3;
},
});
if (kind === null || ackId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
+176
View File
@@ -0,0 +1,176 @@
// @vitest-environment happy-dom
// React Query's internal state updates need an `act`-aware environment
// or it logs noisy warnings. Mirror the useClaimDetail /
// useReconciliation / useTailStream test convention.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useClaimAcks } from "./useClaimAcks";
import { api } from "@/lib/api";
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
// single `api.listClaimAcks` method the hook calls.
vi.mock("@/lib/api", () => ({
api: {
listClaimAcks: vi.fn(),
},
}));
/** Minimal `renderHook` shim — same pattern as `useClaimDetail.test.ts`. */
function renderHook<TResult>(
useCallback: () => TResult
): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Probe() {
result.current = useCallback();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
);
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/** Poll the result until `predicate` is true or we time out. */
async function waitForState(
predicate: () => boolean,
timeoutMs = 1000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForState: predicate did not hold within ${timeoutMs}ms`,
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/** Sample fixture matching the camelCase `ClaimAck` shape (the api
* layer's `mapClaimAck` does the snake_case→camelCase conversion, so
* the mock returns the post-conversion shape directly). */
const SAMPLE_ROWS = [
{
id: 1,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
linkedBy: "auto" as const,
claimState: "received",
},
{
id: 2,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 1,
setControlNumber: "991102990",
setAcceptRejectCode: "R",
linkedAt: "2026-07-02T12:00:01Z",
linkedBy: "auto" as const,
claimState: "received",
},
];
describe("useClaimAcks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("test_returns_idle_state_and_does_not_fetch_when_claimId_is_null", () => {
// Drawer closed → no network call, no loading, no error. The hook
// must short-circuit BEFORE the queryFn is ever invoked so a
// closed drawer is free.
const { result, unmount } = renderHook(() => useClaimAcks(null));
expect(result.current).toBeDefined();
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
expect(result.current?.error).toBeNull();
expect(typeof result.current?.refetch).toBe("function");
expect(api.listClaimAcks).not.toHaveBeenCalled();
unmount();
});
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_claim_id", async () => {
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
SAMPLE_ROWS,
);
const { result, unmount } = renderHook(() => useClaimAcks("CLM-1"));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// The hook must have invoked the API exactly once with the claim id.
expect(api.listClaimAcks).toHaveBeenCalledTimes(1);
expect(api.listClaimAcks).toHaveBeenCalledWith("CLM-1");
const data = result.current?.data;
expect(data).not.toBeNull();
expect(data).toHaveLength(2);
// Both rows belong to the same claim (per-AK2 granularity for 999).
expect(data?.[0]?.claimId).toBe("CLM-1");
expect(data?.[0]?.ackKind).toBe("999");
expect(data?.[0]?.setAcceptRejectCode).toBe("A");
expect(data?.[1]?.setAcceptRejectCode).toBe("R");
expect(data?.[1]?.ak2Index).toBe(1);
// isLoading must flip false once the fetch resolves.
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
unmount();
});
it("test_returns_empty_array_when_claim_has_no_acks", async () => {
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
const { result, unmount } = renderHook(() => useClaimAcks("CLM-empty"));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// Empty list must be surfaced as [] (NOT null), so the ClaimDrawer
// panel can render its <EmptyState /> without a null check.
expect(result.current?.data).toEqual([]);
unmount();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ClaimAck } from "@/types";
/**
* Per-claim acknowledgments query (ClaimDrawer · SP28 §5.1).
*
* Lists every `claim_acks` row for the given claim (per-claim only —
* the backend filters out TA1 batch-level rows with
* `claim_id IS NULL`). Drives the new `<Acknowledgments />` panel
* inside `ClaimDrawer`.
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `claimId === null` (drawer closed): the query is disabled and the
* hook short-circuits to `{ data: null, ... }` so a closed drawer
* doesn't burn a network request or a TanStack cache slot. The
* `ClaimDrawer` panel guards the `data ?? []` access on this.
* - `claimId` is set: fetches
* `GET /api/claims/{claim_id}/acks` via `api.listClaimAcks`.
* Cached 30 s — the link rows don't change once created (only
* manual-match / unlink mutate them, and those fire
* `claim_ack_written` / `claim_ack_dropped` events that the
* live-tail subscription delivers via `useTailStream("claim-acks")`).
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The live-tail subscription (`useTailStream("claim-acks")` +
* `useMergedTail("claim-acks", data ?? [], (link) => link.claimId === claimId)`)
* is mounted by the `ClaimDrawer` itself (per
* `cyclone-frontend-page` convention #3 — subscription on the page,
* not in the hook).
*/
export function useClaimAcks(claimId: string | null): {
data: ClaimAck[] | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<ClaimAck[]>({
queryKey: ["claim-acks", claimId],
queryFn: () => api.listClaimAcks(claimId as string),
enabled: claimId !== null,
staleTime: 30 * 1000,
retry: (failureCount, error) => {
// Don't retry 404 — same posture as the other detail hooks. The
// drawer treats 404 as a distinct "claim doesn't exist" state
// and doesn't want retries masking it.
if (error instanceof Error && "status" in error) {
const status = (error as { status?: unknown }).status;
if (status === 404) return false;
}
return failureCount < 3;
},
});
if (claimId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}