hmc-rag / frontend /src /hooks /useStream.test.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
9.18 kB
/**
* Property-based tests for useStream — request body construction.
*
* **Validates: Requirements 9.2, 9.4**
*
* Property 12: Request body carries selector values
* For any combination of selected profession and language values, every
* POST /api/query/stream request body SHALL contain those exact values in
* the `profession` and `language` fields respectively.
*
* Strategy: intercept `fetch` with a mock that captures the serialised
* request body, then assert the `profession` and `language` fields match
* the values passed to `startStream`. The mock returns a minimal valid SSE
* response so the hook's internal machinery completes cleanly.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fc from "fast-check";
import { renderHook, act } from "@testing-library/react";
import { useStream } from "./useStream";
import type { StreamPayload } from "./useStream";
import { PROFESSIONS, SUPPORTED_LANGUAGES } from "../types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a minimal SSE response body that emits a single `done` event so the
* hook's stream-reading loop terminates cleanly.
*/
function makeSseResponse(donePayload: object = {}): Response {
const body =
"event: done\ndata: " + JSON.stringify(donePayload) + "\n\n";
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
/**
* Install a `fetch` mock that:
* 1. Captures the last request body as a parsed JSON object.
* 2. Returns a minimal SSE `done` response.
*
* Returns a getter for the captured body.
*/
function installFetchMock(): { getCapturedBody: () => Record<string, unknown> | null } {
let capturedBody: Record<string, unknown> | null = null;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.body && typeof init.body === "string") {
capturedBody = JSON.parse(init.body) as Record<string, unknown>;
}
return makeSseResponse({
citations: [],
domains_searched: [],
sections_retrieved: 0,
timing: {},
token_usage: { total_tokens: 0 },
});
})
);
return { getCapturedBody: () => capturedBody };
}
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
/** Arbitrary profession value: one of the known professions or null. */
const professionArb: fc.Arbitrary<string | null> = fc.oneof(
fc.constantFrom(...PROFESSIONS),
fc.constant(null)
);
/** Arbitrary language value: one of the known language codes or null. */
const languageArb: fc.Arbitrary<string | null> = fc.oneof(
fc.constantFrom(...(Object.keys(SUPPORTED_LANGUAGES) as string[])),
fc.constant(null)
);
/** Arbitrary query string (non-empty). */
const queryArb = fc.string({ minLength: 1, maxLength: 200 });
// ---------------------------------------------------------------------------
// Property 12: Request body carries selector values
// ---------------------------------------------------------------------------
describe("Property 12: Request body carries selector values", () => {
/**
* **Validates: Requirements 9.2, 9.4**
*
* For any combination of profession and language values, the POST
* /api/query/stream request body SHALL contain those exact values in the
* `profession` and `language` fields respectively.
*/
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("profession field in request body matches the value passed to startStream", async () => {
await fc.assert(
fc.asyncProperty(
professionArb,
languageArb,
queryArb,
async (profession, language, query) => {
const { getCapturedBody } = installFetchMock();
const { result } = renderHook(() => useStream("test-token"));
const payload: StreamPayload = {
query,
history: [],
profession,
language,
};
await act(async () => {
result.current.startStream(payload, () => {}, () => {});
// Allow the async fetch to complete
await vi.runAllTimersAsync();
});
const body = getCapturedBody();
expect(body).not.toBeNull();
expect(body!["profession"]).toBe(profession);
}
),
{ numRuns: 100 }
);
});
it("language field in request body matches the value passed to startStream", async () => {
await fc.assert(
fc.asyncProperty(
professionArb,
languageArb,
queryArb,
async (profession, language, query) => {
const { getCapturedBody } = installFetchMock();
const { result } = renderHook(() => useStream("test-token"));
const payload: StreamPayload = {
query,
history: [],
profession,
language,
};
await act(async () => {
result.current.startStream(payload, () => {}, () => {});
await vi.runAllTimersAsync();
});
const body = getCapturedBody();
expect(body).not.toBeNull();
expect(body!["language"]).toBe(language);
}
),
{ numRuns: 100 }
);
});
it("both profession and language appear verbatim together in the same request body", async () => {
await fc.assert(
fc.asyncProperty(
professionArb,
languageArb,
queryArb,
async (profession, language, query) => {
const { getCapturedBody } = installFetchMock();
const { result } = renderHook(() => useStream("test-token"));
const payload: StreamPayload = {
query,
history: [],
profession,
language,
};
await act(async () => {
result.current.startStream(payload, () => {}, () => {});
await vi.runAllTimersAsync();
});
const body = getCapturedBody();
expect(body).not.toBeNull();
expect(body!["profession"]).toBe(profession);
expect(body!["language"]).toBe(language);
}
),
{ numRuns: 100 }
);
});
it("query field in request body matches the value passed to startStream", async () => {
await fc.assert(
fc.asyncProperty(
professionArb,
languageArb,
queryArb,
async (profession, language, query) => {
const { getCapturedBody } = installFetchMock();
const { result } = renderHook(() => useStream("test-token"));
const payload: StreamPayload = {
query,
history: [],
profession,
language,
};
await act(async () => {
result.current.startStream(payload, () => {}, () => {});
await vi.runAllTimersAsync();
});
const body = getCapturedBody();
expect(body).not.toBeNull();
expect(body!["query"]).toBe(query);
}
),
{ numRuns: 100 }
);
});
it("null profession is serialised as null (not omitted) in the request body", async () => {
const { getCapturedBody } = installFetchMock();
const { result } = renderHook(() => useStream("test-token"));
await act(async () => {
result.current.startStream(
{ query: "test", history: [], profession: null, language: null },
() => {},
() => {}
);
await vi.runAllTimersAsync();
});
const body = getCapturedBody();
expect(body).not.toBeNull();
// JSON.stringify preserves null values
expect(Object.prototype.hasOwnProperty.call(body, "profession")).toBe(true);
expect(body!["profession"]).toBeNull();
expect(Object.prototype.hasOwnProperty.call(body, "language")).toBe(true);
expect(body!["language"]).toBeNull();
});
it("Authorization header carries the JWT token passed to the hook", async () => {
let capturedHeaders: Record<string, string> | null = null;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
return makeSseResponse({
citations: [],
domains_searched: [],
sections_retrieved: 0,
timing: {},
token_usage: { total_tokens: 0 },
});
})
);
const testToken = "eyJhbGciOiJIUzI1NiJ9.test.signature";
const { result } = renderHook(() => useStream(testToken));
await act(async () => {
result.current.startStream(
{ query: "test", history: [], profession: null, language: null },
() => {},
() => {}
);
await vi.runAllTimersAsync();
});
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!["Authorization"]).toBe(`Bearer ${testToken}`);
});
});