Files
kandev/apps/web/hooks/domains/session/use-clarification-group.test.ts
T

411 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "https://api.test" }),
}));
const mockUpdateMessage = vi.fn();
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({
getState: () => ({ updateMessage: mockUpdateMessage }),
}),
}));
import { useClarificationGroup } from "./use-clarification-group";
function clarMessage(opts: {
id: string;
pendingId: string;
questionId: string;
index: number;
total: number;
}): Message {
return {
id: opts.id,
session_id: toSessionId("s1"),
task_id: toTaskId("t1"),
author_type: "agent",
content: "Q",
type: "clarification_request",
created_at: "2026-05-04T00:00:00Z",
metadata: {
pending_id: opts.pendingId,
question_id: opts.questionId,
question_index: opts.index,
question_total: opts.total,
status: "pending",
question: { id: opts.questionId, prompt: "Q?" },
},
};
}
const fetchMock = vi.fn();
function setupFetchMock() {
fetchMock.mockReset();
mockUpdateMessage.mockReset();
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
}
describe("useClarificationGroup — derived state", () => {
beforeEach(setupFetchMock);
it("derives total + pendingId from the message bundle", () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
expect(result.current.pendingId).toBe("p1");
expect(result.current.total).toBe(2);
expect(result.current.answeredCount).toBe(0);
});
it("returns null pendingId when there are no messages", () => {
const { result } = renderHook(() => useClarificationGroup([]));
expect(result.current.pendingId).toBeNull();
expect(result.current.total).toBe(0);
});
it("recordAnswer updates local state without posting", async () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
result.current.recordAnswer("q1", { question_id: "q1", selected_options: ["o1"] });
});
expect(result.current.answers["q1"]?.selected_options).toEqual(["o1"]);
expect(result.current.answeredCount).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
});
it("clearAnswer removes the entry and decrements answeredCount", async () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
result.current.recordAnswer("q1", { question_id: "q1", custom_text: "draft" });
});
expect(result.current.answeredCount).toBe(1);
await act(async () => {
result.current.clearAnswer("q1");
});
expect(result.current.answers["q1"]).toBeUndefined();
expect(result.current.answeredCount).toBe(0);
// Clearing a question that was never recorded is a no-op.
await act(async () => {
result.current.clearAnswer("q-missing");
});
expect(result.current.answeredCount).toBe(0);
});
});
describe("useClarificationGroup — submit + skip", () => {
beforeEach(setupFetchMock);
it("submitCollected POSTs the batch only when every question has an answer", async () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
result.current.recordAnswer("q1", { question_id: "q1", selected_options: ["o1"] });
});
await act(async () => {
await result.current.submitCollected();
});
expect(fetchMock).not.toHaveBeenCalled();
await act(async () => {
result.current.recordAnswer("q2", { question_id: "q2", custom_text: "free" });
});
await act(async () => {
await result.current.submitCollected();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe("https://api.test/api/v1/clarification/p1/respond");
expect(JSON.parse(String(init.body))).toEqual({
answers: [
{ question_id: "q1", selected_options: ["o1"] },
{ question_id: "q2", custom_text: "free" },
],
rejected: false,
});
expect(result.current.submitState).toBe("ok");
});
it("submitCollected with override merges the freshly recorded answer", async () => {
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.submitCollected({
q1: { question_id: "q1", selected_options: ["o1"] },
});
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.submitState).toBe("ok");
});
it("skipAll POSTs rejected=true with the supplied reason", async () => {
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.skipAll("Too vague");
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init.body))).toEqual({
rejected: true,
reject_reason: "Too vague",
});
expect(result.current.submitState).toBe("ok");
});
it("submitState transitions to 'error' when fetch returns non-OK", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 400 }));
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.submitCollected({
q1: { question_id: "q1", selected_options: ["o1"] },
});
});
expect(result.current.submitState).toBe("error");
});
// 409 Conflict means a duplicate submit landed after the first one already
// resolved the bundle on the backend. Treat it as success so the overlay
// closes instead of getting stuck in an unrecoverable error state.
it("submitState transitions to 'ok' on 409 (duplicate submit)", async () => {
fetchMock.mockResolvedValueOnce(new Response("conflict", { status: 409 }));
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.submitCollected({
q1: { question_id: "q1", selected_options: ["o1"] },
});
});
expect(result.current.submitState).toBe("ok");
});
it("skipAll transitions to 'ok' on 409 too", async () => {
fetchMock.mockResolvedValueOnce(new Response("conflict", { status: 409 }));
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.skipAll();
});
expect(result.current.submitState).toBe("ok");
});
});
// Silent WS dead-socket scenario: when the question has been hanging long enough
// that the underlying WebSocket has gone half-dead (NAT timeout, browser throttle),
// the answer submit still completes via HTTP but the backend's session.message.updated
// broadcast never reaches the dead socket — so the overlay would stay stuck on the
// pending bundle until the user refreshes. To stay robust against that we mark
// each bundle message as answered/rejected in the store the moment the HTTP POST
// resolves, mirroring the backend update the WS event would have delivered.
describe("useClarificationGroup — optimistic store update on resolve", () => {
beforeEach(setupFetchMock);
it("submitCollected marks every bundle message as answered in the store", async () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
const answer1 = { question_id: "q1", selected_options: ["o1"] };
const answer2 = { question_id: "q2", custom_text: "free" };
await act(async () => {
result.current.recordAnswer("q1", answer1);
result.current.recordAnswer("q2", answer2);
});
await act(async () => {
await result.current.submitCollected();
});
expect(result.current.submitState).toBe("ok");
expect(mockUpdateMessage).toHaveBeenCalledTimes(2);
const firstCall = mockUpdateMessage.mock.calls[0][0];
const secondCall = mockUpdateMessage.mock.calls[1][0];
expect(firstCall.id).toBe("m1");
expect(firstCall.metadata.status).toBe("answered");
expect(firstCall.metadata.response).toEqual(answer1);
expect(firstCall.metadata.pending_id).toBe("p1");
expect(secondCall.id).toBe("m2");
expect(secondCall.metadata.status).toBe("answered");
expect(secondCall.metadata.response).toEqual(answer2);
});
it("skipAll marks every bundle message as rejected in the store", async () => {
const msgs = [
clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }),
clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }),
];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.skipAll("Too vague");
});
expect(result.current.submitState).toBe("ok");
expect(mockUpdateMessage).toHaveBeenCalledTimes(2);
const calls = mockUpdateMessage.mock.calls.map((c) => c[0]);
expect(calls.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
for (const m of calls) {
expect(m.metadata.status).toBe("rejected");
expect(m.metadata.pending_id).toBe("p1");
}
});
it("submitCollected does NOT touch the store when the HTTP request fails", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 400 }));
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.submitCollected({
q1: { question_id: "q1", selected_options: ["o1"] },
});
});
expect(result.current.submitState).toBe("error");
expect(mockUpdateMessage).not.toHaveBeenCalled();
});
});
describe("useClarificationGroup — optimistic store update edge cases", () => {
beforeEach(setupFetchMock);
// Race guard (cubic P1): if the parent re-renders the hook with a different
// bundle after the POST is in flight (e.g. the next clarification has
// already streamed in), the optimistic update must still target the bundle
// that was *submitted* — not whatever the live messages prop points at when
// the await resolves. We capture a snapshot at submit time.
it("submitCollected applies the optimistic update to the submit-time bundle, not the latest one", async () => {
let resolveFetch: ((res: Response) => void) | null = null;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const initial = [
clarMessage({ id: "m-old", pendingId: "p-old", questionId: "q-old", index: 0, total: 1 }),
];
const next = [
clarMessage({ id: "m-new", pendingId: "p-new", questionId: "q-new", index: 0, total: 1 }),
];
const { result, rerender } = renderHook(({ msgs }) => useClarificationGroup(msgs), {
initialProps: { msgs: initial },
});
await act(async () => {
// Submit kicks off the POST; rerender swaps the bundle while in flight;
// resolveFetch unblocks the POST and the optimistic update runs.
const pending = result.current.submitCollected({
"q-old": { question_id: "q-old", selected_options: ["o1"] },
});
rerender({ msgs: next });
resolveFetch?.(new Response(null, { status: 200 }));
await pending;
});
expect(mockUpdateMessage).toHaveBeenCalledTimes(1);
expect(mockUpdateMessage.mock.calls[0][0].id).toBe("m-old");
expect(mockUpdateMessage.mock.calls[0][0].metadata.pending_id).toBe("p-old");
});
// Failure-isolation guard (greptile P1): the optimistic store update is a
// best-effort UI nicety — if it blows up (e.g. the store action is missing,
// immer throws on a frozen object), the HTTP submit still succeeded and the
// user must see submitState === "ok", not "error".
it("submitCollected stays 'ok' when the optimistic store update throws", async () => {
mockUpdateMessage.mockImplementationOnce(() => {
throw new Error("store update boom");
});
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.submitCollected({
q1: { question_id: "q1", selected_options: ["o1"] },
});
});
expect(result.current.submitState).toBe("ok");
});
it("skipAll stays 'ok' when the optimistic store update throws", async () => {
mockUpdateMessage.mockImplementationOnce(() => {
throw new Error("store update boom");
});
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
await result.current.skipAll();
});
expect(result.current.submitState).toBe("ok");
});
});
describe("useClarificationGroup — inflight guard", () => {
beforeEach(setupFetchMock);
// Cmd+Enter inside the custom-text input historically reached both onSubmit
// and onRequestFinalSubmit, which could fire submitCollected twice in the
// same tick. The hook's inflight ref must keep the wire count at 1 even if
// the UI races; the backend would otherwise see a duplicate POST.
it("submitCollected guards against concurrent calls", async () => {
let resolveFetch: ((res: Response) => void) | null = null;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })];
const { result } = renderHook(() => useClarificationGroup(msgs));
await act(async () => {
result.current.recordAnswer("q1", { question_id: "q1", selected_options: ["o1"] });
});
await act(async () => {
const first = result.current.submitCollected();
const second = result.current.submitCollected();
resolveFetch?.(new Response(null, { status: 200 }));
await Promise.all([first, second]);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.submitState).toBe("ok");
});
});