/** * 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 | null } { let capturedBody: Record | 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; } 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 = fc.oneof( fc.constantFrom(...PROFESSIONS), fc.constant(null) ); /** Arbitrary language value: one of the known language codes or null. */ const languageArb: fc.Arbitrary = 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 | null = null; vi.stubGlobal( "fetch", vi.fn(async (_url: string, init?: RequestInit) => { capturedHeaders = (init?.headers ?? {}) as Record; 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}`); }); });