From 129290517e7d2ab9998807ebeb91c037784a99b3 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 17:49:23 -0500 Subject: [PATCH] docs: add handoff report and frontend testing research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BATSHIT_REPORT.md: full context on bulk query batching — business goal, approach, all changes, verification status, and how to run. FRONTEND_TEST_RESEARCH.md: research on unit testing react-query hooks with jest.mock, renderHook, and batcher testing patterns. --- BATSHIT_REPORT.md | 167 ++++++++++++ FRONTEND_TEST_RESEARCH.md | 533 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 BATSHIT_REPORT.md create mode 100644 FRONTEND_TEST_RESEARCH.md diff --git a/BATSHIT_REPORT.md b/BATSHIT_REPORT.md new file mode 100644 index 00000000..777239d6 --- /dev/null +++ b/BATSHIT_REPORT.md @@ -0,0 +1,167 @@ +# Batch Room Meeting Status Queries — Handoff Report + +## Business Goal + +The rooms list page (`/rooms`) fires **2N+2 HTTP requests** for N rooms. Each room card renders a `MeetingStatus` component that independently calls two hooks: + +- `useRoomActiveMeetings(roomName)` → `GET /v1/rooms/{room_name}/meetings/active` +- `useRoomUpcomingMeetings(roomName)` → `GET /v1/rooms/{room_name}/meetings/upcoming` + +Each of those endpoints internally does a room lookup by name (`LIMIT 1`) plus a data query. For 10 rooms, that's 22 HTTP requests and 20 DB queries on page load. This is a classic N+1 problem at the API layer. + +**Goal: collapse all per-room meeting status queries into a single bulk POST request.** + +## Approach: DataLoader-style Batching + +We use [`@yornaath/batshit`](https://github.com/yornaath/batshit) — a lightweight DataLoader pattern for JS. It collects individual `.fetch(roomName)` calls within a 10ms window, deduplicates them, and dispatches one bulk request. Each caller gets back only their slice of the response. + +This is **isomorphic**: removing the batcher and reverting the hooks to direct API calls would still work. The backend bulk endpoint is additive, the individual endpoints remain untouched. + +## What Was Built + +### Branch + +`fix-room-query-batching` (worktree at `.worktrees/fix-room-query-batching/`) + +### Backend Changes + +**New bulk DB methods** (3 files): + +| File | Method | Purpose | +|------|--------|---------| +| `server/reflector/db/rooms.py` | `RoomController.get_by_names(names)` | Fetch rooms by name list using `IN` clause | +| `server/reflector/db/meetings.py` | `MeetingController.get_all_active_for_rooms(room_ids, current_time)` | Active meetings for multiple rooms, one query | +| `server/reflector/db/calendar_events.py` | `CalendarEventController.get_upcoming_for_rooms(room_ids, minutes_ahead)` | Upcoming events for multiple rooms, one query | + +**New endpoint** in `server/reflector/views/rooms.py`: + +``` +POST /v1/rooms/meetings/bulk-status +Body: { "room_names": ["room-a", "room-b", ...] } +Response: { "room-a": { "active_meetings": [...], "upcoming_events": [...] }, ... } +``` + +- 3 total DB queries regardless of room count (rooms lookup, active meetings, upcoming events) +- Auth masking applied: non-owners get `host_room_url=""` for Whereby, `description=null`/`attendees=null` for calendar events +- Registered before `/{room_id}` route to avoid path conflict +- Request/response models: `BulkStatusRequest`, `RoomMeetingStatus` + +### Frontend Changes + +**New dependency**: `@yornaath/batshit` (added to `www/package.json`) + +**New file**: `www/app/lib/meetingStatusBatcher.ts` + +```typescript +export const meetingStatusBatcher = create({ + fetcher: async (roomNames: string[]) => { + const unique = [...new Set(roomNames)]; + const { data } = await client.POST("/v1/rooms/meetings/bulk-status", { + body: { room_names: unique }, + }); + return roomNames.map((name) => ({ + roomName: name, + active_meetings: data?.[name]?.active_meetings ?? [], + upcoming_events: data?.[name]?.upcoming_events ?? [], + })); + }, + resolver: keyResolver("roomName"), + scheduler: windowScheduler(10), // 10ms batching window +}); +``` + +**Modified hooks** in `www/app/lib/apiHooks.ts`: + +- `useRoomActiveMeetings` — changed from `$api.useQuery("get", "/v1/rooms/{room_name}/meetings/active", ...)` to `useQuery` + `meetingStatusBatcher.fetch(roomName)` +- `useRoomUpcomingMeetings` — same pattern +- `useRoomsCreateMeeting` — cache invalidation updated from `$api.queryOptions(...)` to `meetingStatusKeys.active(roomName)` +- Added `meetingStatusKeys` query key factory: `{ active: (name) => ["rooms", name, "meetings/active"], upcoming: (name) => ["rooms", name, "meetings/upcoming"] }` + +**Cache invalidation compatibility**: the new query keys contain `"meetings/active"` and `"meetings/upcoming"` as string elements, which the existing `useMeetingDeactivate` predicate matches via `.includes()`. No changes needed there. + +**Regenerated**: `www/app/reflector-api.d.ts` from OpenAPI spec. + +### Files Modified + +| File | Change | +|------|--------| +| `server/reflector/db/rooms.py` | +`get_by_names()` | +| `server/reflector/db/meetings.py` | +`get_all_active_for_rooms()` | +| `server/reflector/db/calendar_events.py` | +`get_upcoming_for_rooms()` | +| `server/reflector/views/rooms.py` | +endpoint, +`BulkStatusRequest`, +`RoomMeetingStatus`, +imports (`asyncio`, `defaultdict`) | +| `www/package.json` | +`@yornaath/batshit` | +| `www/pnpm-lock.yaml` | Updated | +| `www/app/lib/meetingStatusBatcher.ts` | **New file** | +| `www/app/lib/apiHooks.ts` | Rewrote 2 hooks, added key factory, updated 1 invalidation | +| `www/app/reflector-api.d.ts` | Regenerated | + +## Verification Status + +**Tested:** +- Backend lint (ruff): clean +- Backend tests: 351 passed, 8 skipped (2 pre-existing flaky tests unrelated to this work: `test_transcript_rtc_and_websocket`, `test_transcript_upload_file`) +- TypeScript type check (`tsc --noEmit`): clean +- OpenAPI spec: bulk-status endpoint present and correctly typed +- Pre-commit hooks: all passed + +**NOT tested (requires manual browser verification):** +- Open rooms list page → Network tab shows single `POST /v1/rooms/meetings/bulk-status` instead of 2N GETs +- Active meeting badges render correctly per room +- Upcoming meeting indicators render correctly per room +- Single room page (`MeetingSelection.tsx`) still works (batcher handles batch-of-1) +- Meeting deactivation → cache invalidates and meeting status refreshes +- Creating a meeting → active meetings badge updates + +## Frontend Testing (Next Steps) + +See `FRONTEND_TEST_RESEARCH.md` for a full research document on how to write unit tests for these hooks. Summary: + +- **Approach**: `jest.mock()` on module-level `apiClient` and `meetingStatusBatcher`, `renderHook`/`waitFor` from `@testing-library/react` +- **Batcher testing**: unit test batcher directly with mock `client.POST`; test hooks with mock batcher module +- **New deps needed**: `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/dom`, `jest-environment-jsdom` +- **Key gotcha**: `openapi-react-query` reconstructed from mock client to test actual integration, or mock `$api` methods directly +- **Potential issues**: `ts-jest` v29 / Jest 30 compatibility, ESM handling for `openapi-react-query` + +## How to Run + +### Backend (Docker, from worktree) + +```bash +cd .worktrees/fix-room-query-batching + +# Symlink env files (not in git) +ln -sf /path/to/main/server/.env server/.env +ln -sf /path/to/main/www/.env.local www/.env.local +ln -sf /path/to/main/www/.env www/.env + +# Start services +docker compose up -d redis postgres server +``` + +### Frontend (manual, per project convention) + +```bash +cd .worktrees/fix-room-query-batching/www +pnpm install # if not done +pnpm dev +``` + +### Backend Tests + +```bash +cd .worktrees/fix-room-query-batching/server +REDIS_HOST=localhost CELERY_BROKER_URL=redis://localhost:6379/1 CELERY_RESULT_BACKEND=redis://localhost:6379/1 uv run pytest tests/ -q +``` + +### Regenerate OpenAPI Types + +Requires the backend server running on port 1250: + +```bash +cd .worktrees/fix-room-query-batching/server +REDIS_HOST=localhost CELERY_BROKER_URL=redis://localhost:6379/1 CELERY_RESULT_BACKEND=redis://localhost:6379/1 \ + uv run python -c "import json; from reflector.app import app; json.dump(app.openapi(), open('/tmp/openapi.json','w'))" 2>/dev/null + +cd ../www +npx openapi-typescript /tmp/openapi.json -o ./app/reflector-api.d.ts +``` diff --git a/FRONTEND_TEST_RESEARCH.md b/FRONTEND_TEST_RESEARCH.md new file mode 100644 index 00000000..6330f9d6 --- /dev/null +++ b/FRONTEND_TEST_RESEARCH.md @@ -0,0 +1,533 @@ +# Frontend Hook Testing Research + +## Context + +Hooks in `www/app/lib/apiHooks.ts` use two patterns: +1. `$api.useQuery("get", "/v1/rooms", ...)` -- openapi-react-query wrapping openapi-fetch +2. `useQuery({ queryFn: () => meetingStatusBatcher.fetch(roomName!) })` -- plain react-query with batshit batcher + +Key dependencies: module-level `client` and `$api` in `apiClient.tsx`, module-level `meetingStatusBatcher` in `meetingStatusBatcher.ts`, `useAuth()` context, `useError()` context. + +--- + +## 1. Recommended Mocking Strategy + +**Use `jest.mock()` on the module-level clients + `renderHook` from `@testing-library/react`.** + +Reasons against MSW: +- `openapi-fetch` creates its `client` at module import time. MSW's `server.listen()` must run before the client is created (see [openapi-ts/openapi-typescript#1878](https://github.com/openapi-ts/openapi-typescript/issues/1878)). This requires dynamic imports or Proxy workarounds -- fragile. +- MSW adds infrastructure overhead (handlers, server lifecycle) for unit tests that only need to verify hook behavior. +- The batcher calls `client.POST(...)` directly, not `fetch()`. Mocking the client is more direct. + +Reasons against `openapi-fetch-mock` middleware: +- Requires `client.use()` / `client.eject()` per test -- less isolated than module mock. +- Our `client` already has auth middleware; test middleware ordering gets messy. + +**Winner: Direct module mocking with `jest.mock()`.** + +### Pattern for `$api` hooks + +Mock the entire `apiClient` module. The `$api.useQuery` / `$api.useMutation` from openapi-react-query are just wrappers around react-query that call `client.GET`, `client.POST`, etc. Mock at the `client` method level: + +```ts +// __mocks__ or inline +jest.mock("../apiClient", () => { + const mockClient = { + GET: jest.fn(), + POST: jest.fn(), + PUT: jest.fn(), + PATCH: jest.fn(), + DELETE: jest.fn(), + use: jest.fn(), + }; + const createFetchClient = require("openapi-react-query").default; + return { + client: mockClient, + $api: createFetchClient(mockClient), + API_URL: "http://test", + WEBSOCKET_URL: "ws://test", + configureApiAuth: jest.fn(), + }; +}); +``` + +**Problem**: `openapi-react-query` calls `client.GET(path, init)` and expects `{ data, error, response }`. So the mock must return that shape: + +```ts +import { client } from "../apiClient"; +const mockClient = client as jest.Mocked; + +mockClient.GET.mockResolvedValue({ + data: { items: [], total: 0 }, + error: undefined, + response: new Response(), +}); +``` + +### Pattern for batcher hooks + +Mock the batcher module: + +```ts +jest.mock("../meetingStatusBatcher", () => ({ + meetingStatusBatcher: { + fetch: jest.fn(), + }, +})); + +import { meetingStatusBatcher } from "../meetingStatusBatcher"; +const mockBatcher = meetingStatusBatcher as jest.Mocked; + +mockBatcher.fetch.mockResolvedValue({ + roomName: "test-room", + active_meetings: [{ id: "m1" }], + upcoming_events: [], +}); +``` + +--- + +## 2. renderHook: Use `@testing-library/react` (NOT `@testing-library/react-hooks`) + +`@testing-library/react-hooks` is **deprecated** since React 18. The `renderHook` function is now built into `@testing-library/react` v13+. + +```ts +import { renderHook, waitFor } from "@testing-library/react"; +``` + +Key differences from the old package: +- No `waitForNextUpdate` -- use `waitFor(() => expect(...))` instead +- No separate `result.current.error` for error boundaries +- Works with React 18 concurrent features + +--- + +## 3. Mocking/Intercepting openapi-fetch Client + +Three viable approaches ranked by simplicity: + +### A. Mock the module (recommended) + +As shown above. `jest.mock("../apiClient")` replaces the entire module. The `$api` wrapper can be reconstructed from the mock client using the real `openapi-react-query` factory, or you can mock `$api` methods directly. + +**Caveat**: If you reconstruct `$api` from a mock client, `$api.useQuery` will actually call `mockClient.GET(path, init)`. This is good -- it tests the integration between openapi-react-query and the client. + +### B. Inject mock fetch into client + +```ts +const mockFetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { status: 200 }) +); +const testClient = createClient({ baseUrl: "http://test", fetch: mockFetch }); +``` + +Problem: requires the hooks to accept the client as a parameter or use dependency injection. Our hooks use module-level `$api` -- doesn't work without refactoring. + +### C. MSW (not recommended for unit tests) + +As discussed, the module-import-time client creation conflicts with MSW's server lifecycle. Workable but fragile. + +--- + +## 4. Testing Batshit Batcher Behavior + +### Unit test the batcher directly (no hooks) + +The batcher is a pure module -- test it without React: + +```ts +import { create, keyResolver, windowScheduler } from "@yornaath/batshit"; + +test("batches multiple room queries into one POST", async () => { + const mockPost = jest.fn().mockResolvedValue({ + data: { + "room-a": { active_meetings: [], upcoming_events: [] }, + "room-b": { active_meetings: [], upcoming_events: [] }, + }, + }); + + const batcher = create({ + fetcher: async (roomNames: string[]) => { + const unique = [...new Set(roomNames)]; + const { data } = await mockPost("/v1/rooms/meetings/bulk-status", { + body: { room_names: unique }, + }); + return roomNames.map((name) => ({ + roomName: name, + active_meetings: data?.[name]?.active_meetings ?? [], + upcoming_events: data?.[name]?.upcoming_events ?? [], + })); + }, + resolver: keyResolver("roomName"), + scheduler: windowScheduler(10), + }); + + // Fire multiple fetches within the 10ms window + const [resultA, resultB] = await Promise.all([ + batcher.fetch("room-a"), + batcher.fetch("room-b"), + ]); + + // Only one POST call + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledWith("/v1/rooms/meetings/bulk-status", { + body: { room_names: ["room-a", "room-b"] }, + }); + + expect(resultA.active_meetings).toEqual([]); + expect(resultB.active_meetings).toEqual([]); +}); + +test("deduplicates same room name", async () => { + const mockPost = jest.fn().mockResolvedValue({ + data: { "room-a": { active_meetings: [{ id: "m1" }], upcoming_events: [] } }, + }); + + const batcher = create({ + fetcher: async (roomNames: string[]) => { + const unique = [...new Set(roomNames)]; + const { data } = await mockPost("/v1/rooms/meetings/bulk-status", { + body: { room_names: unique }, + }); + return roomNames.map((name) => ({ + roomName: name, + active_meetings: data?.[name]?.active_meetings ?? [], + upcoming_events: data?.[name]?.upcoming_events ?? [], + })); + }, + resolver: keyResolver("roomName"), + scheduler: windowScheduler(10), + }); + + const [r1, r2] = await Promise.all([ + batcher.fetch("room-a"), + batcher.fetch("room-a"), + ]); + + expect(mockPost).toHaveBeenCalledTimes(1); + // Both resolve to same data + expect(r1.active_meetings).toEqual([{ id: "m1" }]); + expect(r2.active_meetings).toEqual([{ id: "m1" }]); +}); +``` + +### Test the actual batcher module with mocked client + +```ts +jest.mock("../apiClient", () => ({ + client: { POST: jest.fn() }, +})); + +import { client } from "../apiClient"; +import { meetingStatusBatcher } from "../meetingStatusBatcher"; + +const mockClient = client as jest.Mocked; + +test("meetingStatusBatcher calls bulk-status endpoint", async () => { + mockClient.POST.mockResolvedValue({ + data: { "room-x": { active_meetings: [], upcoming_events: [] } }, + error: undefined, + response: new Response(), + }); + + const result = await meetingStatusBatcher.fetch("room-x"); + expect(result.active_meetings).toEqual([]); + expect(mockClient.POST).toHaveBeenCalledWith( + "/v1/rooms/meetings/bulk-status", + expect.objectContaining({ body: { room_names: ["room-x"] } }), + ); +}); +``` + +--- + +## 5. Minimal Jest Setup + +### Install dependencies + +```bash +cd www && pnpm add -D @testing-library/react @testing-library/jest-dom jest-environment-jsdom +``` + +Note: `jest` v30 and `ts-jest` are already in devDependencies. `@types/jest` v30 is already present. `@testing-library/react` v16 supports React 18. + +### jest.config.ts + +```ts +import type { Config } from "jest"; + +const config: Config = { + testEnvironment: "jest-environment-jsdom", + transform: { + "^.+\\.tsx?$": ["ts-jest", { + tsconfig: "tsconfig.json", + }], + }, + moduleNameMapper: { + // Handle module aliases if you add them later + // "^@/(.*)$": "/$1", + }, + setupFilesAfterSetup: ["/jest.setup.ts"], + // Ignore Next.js build output + testPathIgnorePatterns: ["/.next/", "/node_modules/"], +}; + +export default config; +``` + +### jest.setup.ts + +```ts +import "@testing-library/jest-dom"; +``` + +### Mocking modules that fail in jsdom + +The `apiClient.tsx` module calls `getSession()` and `getClientEnv()` at import time. These will fail in jsdom. Mock them: + +```ts +// In test file or __mocks__ +jest.mock("next-auth/react", () => ({ + getSession: jest.fn().mockResolvedValue(null), + signIn: jest.fn(), + signOut: jest.fn(), + useSession: jest.fn().mockReturnValue({ data: null, status: "unauthenticated" }), +})); + +jest.mock("../next", () => ({ + isBuildPhase: false, +})); + +jest.mock("../clientEnv", () => ({ + getClientEnv: () => ({ + API_URL: "http://test-api", + WEBSOCKET_URL: "ws://test-api", + FEATURE_REQUIRE_LOGIN: false, + FEATURE_PRIVACY: null, + FEATURE_BROWSE: null, + FEATURE_SEND_TO_ZULIP: null, + FEATURE_ROOMS: null, + }), +})); +``` + +--- + +## 6. Testing Hooks Without Prop-Based DI + +The hooks use module-level `$api` and `meetingStatusBatcher`. No way to inject via props. Two approaches: + +### A. `jest.mock()` the modules (recommended) + +Already shown. Works cleanly. Each test can `mockResolvedValue` differently. + +### B. Manual mock files (`__mocks__/`) + +Create `www/app/lib/__mocks__/apiClient.tsx`: + +```ts +const mockClient = { + GET: jest.fn(), + POST: jest.fn(), + PUT: jest.fn(), + PATCH: jest.fn(), + DELETE: jest.fn(), + use: jest.fn(), +}; + +// Use real openapi-react-query wrapping the mock client +const createFetchClient = jest.requireActual("openapi-react-query").default; + +export const client = mockClient; +export const $api = createFetchClient(mockClient); +export const API_URL = "http://test"; +export const WEBSOCKET_URL = "ws://test"; +export const configureApiAuth = jest.fn(); +``` + +Then in tests: `jest.mock("../apiClient")` with no factory -- picks up the `__mocks__` file automatically. + +--- + +## 7. Complete Example Test + +```ts +// www/app/lib/__tests__/apiHooks.test.ts + +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; + +// Mock modules before imports +jest.mock("next-auth/react", () => ({ + getSession: jest.fn().mockResolvedValue(null), + signIn: jest.fn(), + signOut: jest.fn(), + useSession: jest.fn().mockReturnValue({ + data: null, + status: "unauthenticated", + }), +})); + +jest.mock("../clientEnv", () => ({ + getClientEnv: () => ({ + API_URL: "http://test", + WEBSOCKET_URL: "ws://test", + FEATURE_REQUIRE_LOGIN: false, + FEATURE_PRIVACY: null, + FEATURE_BROWSE: null, + FEATURE_SEND_TO_ZULIP: null, + FEATURE_ROOMS: null, + }), +})); + +jest.mock("../next", () => ({ isBuildPhase: false })); + +jest.mock("../meetingStatusBatcher", () => ({ + meetingStatusBatcher: { fetch: jest.fn() }, +})); + +// Must mock apiClient BEFORE importing hooks +jest.mock("../apiClient", () => { + const mockClient = { + GET: jest.fn(), + POST: jest.fn(), + PATCH: jest.fn(), + DELETE: jest.fn(), + use: jest.fn(), + }; + const createFetchClient = + jest.requireActual("openapi-react-query").default; + return { + client: mockClient, + $api: createFetchClient(mockClient), + API_URL: "http://test", + WEBSOCKET_URL: "ws://test", + configureApiAuth: jest.fn(), + }; +}); + +// Now import the hooks under test +import { useRoomActiveMeetings, useTranscriptsSearch } from "../apiHooks"; +import { client } from "../apiClient"; +import { meetingStatusBatcher } from "../meetingStatusBatcher"; +import { ErrorProvider } from "../../(errors)/errorContext"; + +const mockClient = client as jest.Mocked; +const mockBatcher = meetingStatusBatcher as { fetch: jest.Mock }; + +// Wrapper with required providers +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(ErrorProvider, null, children), + ); + }; +} + +describe("useRoomActiveMeetings", () => { + afterEach(() => jest.clearAllMocks()); + + it("returns active meetings from batcher", async () => { + mockBatcher.fetch.mockResolvedValue({ + roomName: "test-room", + active_meetings: [{ id: "m1", room_name: "test-room" }], + upcoming_events: [], + }); + + const { result } = renderHook( + () => useRoomActiveMeetings("test-room"), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual([ + { id: "m1", room_name: "test-room" }, + ]); + expect(mockBatcher.fetch).toHaveBeenCalledWith("test-room"); + }); + + it("is disabled when roomName is null", () => { + const { result } = renderHook( + () => useRoomActiveMeetings(null), + { wrapper: createWrapper() }, + ); + + expect(result.current.fetchStatus).toBe("idle"); + expect(mockBatcher.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("useTranscriptsSearch", () => { + afterEach(() => jest.clearAllMocks()); + + it("fetches transcripts via $api", async () => { + mockClient.GET.mockResolvedValue({ + data: { items: [{ id: "t1", title: "Test" }], total: 1 }, + error: undefined, + response: new Response(), + }); + + const { result } = renderHook( + () => useTranscriptsSearch("hello"), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + items: [{ id: "t1", title: "Test" }], + total: 1, + }); + expect(mockClient.GET).toHaveBeenCalledWith( + "/v1/transcripts/search", + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ q: "hello" }), + }), + }), + ); + }); +}); +``` + +--- + +## 8. Summary of Decisions + +| Question | Answer | +|----------|--------| +| Mocking approach | `jest.mock()` on module-level clients | +| renderHook source | `@testing-library/react` (not deprecated hooks lib) | +| Intercept openapi-fetch | Mock `client.GET/POST/...` methods, reconstruct `$api` with real `openapi-react-query` | +| Test batcher | Unit test batcher directly with mock POST fn; test hooks with mock batcher module | +| Auth context | Mock `next-auth/react`, disable `requireLogin` feature flag | +| Error context | Wrap with real `ErrorProvider` (it's simple state) | +| QueryClient | New instance per test, `retry: false` | + +### New packages needed + +```bash +cd www && pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/dom jest-environment-jsdom +``` + +### Files to create + +1. `www/jest.config.ts` -- jest configuration +2. `www/jest.setup.ts` -- `import "@testing-library/jest-dom"` +3. `www/app/lib/__tests__/apiHooks.test.ts` -- hook tests +4. `www/app/lib/__tests__/meetingStatusBatcher.test.ts` -- batcher unit tests + +### Potential issues + +- `ts-jest` v29 may not fully support Jest 30. Watch for compatibility errors. May need `ts-jest@next` or switch to `@swc/jest`. +- `openapi-react-query` imports may need ESM handling in Jest. If `jest.requireActual("openapi-react-query")` fails, mock `$api` methods directly instead of reconstructing. +- `"use client"` directive at top of `apiHooks.ts` / `apiClient.tsx` -- Jest ignores this (it's a no-op outside Next.js bundler), but verify it doesn't cause parse errors.