mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-02-06 10:46:46 +00:00
docs: add handoff report and frontend testing research
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.
This commit is contained in:
167
BATSHIT_REPORT.md
Normal file
167
BATSHIT_REPORT.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
533
FRONTEND_TEST_RESEARCH.md
Normal file
533
FRONTEND_TEST_RESEARCH.md
Normal file
@@ -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<typeof client>;
|
||||||
|
|
||||||
|
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<typeof meetingStatusBatcher>;
|
||||||
|
|
||||||
|
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<paths>({ 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<typeof client>;
|
||||||
|
|
||||||
|
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
|
||||||
|
// "^@/(.*)$": "<rootDir>/$1",
|
||||||
|
},
|
||||||
|
setupFilesAfterSetup: ["<rootDir>/jest.setup.ts"],
|
||||||
|
// Ignore Next.js build output
|
||||||
|
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/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<typeof client>;
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user