Spaces:
Runtime error
Runtime error
| /** | |
| * 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}`); | |
| }); | |
| }); | |