feat(sp28): useClaimAcks + useAckClaims hooks with api types
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user