fix: batch room meeting status queries via prop-drilling

Alternative to the batcher approach (#848): parent fetches all room
meeting statuses in a single bulk POST and passes data down as props.
No extra dependency (@yornaath/batshit), no implicit batching magic.

Backend: POST /v1/rooms/meetings/bulk-status + bulk DB methods.
Frontend: useRoomsBulkMeetingStatus hook in RoomList, MeetingStatus
receives data as props instead of calling per-room hooks.
CI: fix pnpm 8→10 auto-detect, add concurrency group.
Tests: Jest+jsdom+testing-library for bulk hook.
This commit is contained in:
Igor Loskutov
2026-02-05 20:04:31 -05:00
parent 1ce1c7a910
commit 083a50cbcd
13 changed files with 1253 additions and 54 deletions

View File

@@ -0,0 +1,197 @@
import "@testing-library/jest-dom";
// --- Module mocks (hoisted before imports) ---
jest.mock("../apiClient", () => ({
client: {
GET: jest.fn(),
POST: jest.fn(),
PUT: jest.fn(),
PATCH: jest.fn(),
DELETE: jest.fn(),
use: jest.fn(),
},
$api: {
useQuery: jest.fn(),
useMutation: jest.fn(),
queryOptions: (method: string, path: string, init?: unknown) =>
init === undefined
? { queryKey: [method, path] }
: { queryKey: [method, path, init] },
},
API_URL: "http://test",
WEBSOCKET_URL: "ws://test",
configureApiAuth: jest.fn(),
}));
jest.mock("../AuthProvider", () => ({
useAuth: () => ({
status: "authenticated" as const,
accessToken: "test-token",
accessTokenExpires: Date.now() + 3600000,
user: { id: "user1", name: "Test User" },
update: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
lastUserId: "user1",
}),
}));
// --- Imports (after mocks) ---
import React from "react";
import { render, waitFor, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRoomsBulkMeetingStatus, BulkMeetingStatusMap } from "../apiHooks";
import { client } from "../apiClient";
import { ErrorProvider } from "../../(errors)/errorContext";
const mockClient = client as { POST: jest.Mock };
// --- Helpers ---
function mockBulkStatusEndpoint(
roomData?: Record<
string,
{ active_meetings: unknown[]; upcoming_events: unknown[] }
>,
) {
mockClient.POST.mockImplementation(
async (_path: string, options: { body: { room_names: string[] } }) => {
const roomNames: string[] = options.body.room_names;
const src = roomData ?? {};
const data = Object.fromEntries(
roomNames.map((name) => [
name,
src[name] ?? { active_meetings: [], upcoming_events: [] },
]),
);
return { data, error: undefined, response: {} };
},
);
}
// --- Test component: uses the bulk hook and displays results ---
function BulkStatusDisplay({ roomNames }: { roomNames: string[] }) {
const { data, isLoading } = useRoomsBulkMeetingStatus(roomNames);
if (isLoading) {
return <div data-testid="status">loading</div>;
}
if (!data) {
return <div data-testid="status">no data</div>;
}
return (
<div data-testid="status">
{roomNames.map((name) => {
const status = data[name];
return (
<div key={name} data-testid={`room-${name}`}>
{status?.active_meetings?.length ?? 0} active,{" "}
{status?.upcoming_events?.length ?? 0} upcoming
</div>
);
})}
</div>
);
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ErrorProvider>{children}</ErrorProvider>
</QueryClientProvider>
);
};
}
// --- Tests ---
describe("bulk meeting status (prop-drilling)", () => {
afterEach(() => jest.clearAllMocks());
it("fetches all room statuses in a single POST request", async () => {
const rooms = Array.from({ length: 10 }, (_, i) => `room-${i}`);
mockBulkStatusEndpoint();
render(<BulkStatusDisplay roomNames={rooms} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
for (const name of rooms) {
expect(screen.getByTestId(`room-${name}`)).toHaveTextContent(
"0 active, 0 upcoming",
);
}
});
const postCalls = mockClient.POST.mock.calls.filter(
([path]: [string]) => path === "/v1/rooms/meetings/bulk-status",
);
// Prop-drilling: exactly 1 POST for all rooms (no batcher needed)
expect(postCalls).toHaveLength(1);
// The single call contains all room names
const requestedRooms: string[] = postCalls[0][1].body.room_names;
expect(requestedRooms).toHaveLength(10);
for (const name of rooms) {
expect(requestedRooms).toContain(name);
}
});
it("returns room-specific data correctly", async () => {
mockBulkStatusEndpoint({
"room-a": {
active_meetings: [{ id: "m1", room_name: "room-a" }],
upcoming_events: [],
},
"room-b": {
active_meetings: [],
upcoming_events: [{ id: "e1", title: "Standup" }],
},
});
render(<BulkStatusDisplay roomNames={["room-a", "room-b"]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("room-room-a")).toHaveTextContent(
"1 active, 0 upcoming",
);
expect(screen.getByTestId("room-room-b")).toHaveTextContent(
"0 active, 1 upcoming",
);
});
// Still just 1 POST
expect(mockClient.POST).toHaveBeenCalledTimes(1);
});
it("does not fetch when roomNames is empty", async () => {
mockBulkStatusEndpoint();
render(<BulkStatusDisplay roomNames={[]} />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("status")).toHaveTextContent("no data");
});
// No POST calls when no rooms
expect(mockClient.POST).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,8 @@
"use client";
import { $api } from "./apiClient";
import { $api, client } from "./apiClient";
import { useError } from "../(errors)/errorContext";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider";
import { MeetingId } from "./types";
@@ -641,16 +641,21 @@ export function useMeetingDeactivate() {
setError(error as Error, "Failed to end meeting");
},
onSuccess: () => {
return queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key.some(
(k) =>
typeof k === "string" &&
!!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)),
);
},
});
return Promise.all([
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key.some(
(k) =>
typeof k === "string" &&
!!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)),
);
},
}),
queryClient.invalidateQueries({
queryKey: ["bulk-meeting-status"],
}),
]);
},
});
}
@@ -707,6 +712,9 @@ export function useRoomsCreateMeeting() {
},
).queryKey,
}),
queryClient.invalidateQueries({
queryKey: ["bulk-meeting-status"],
}),
]);
},
onError: (error) => {
@@ -772,6 +780,32 @@ export function useRoomActiveMeetings(roomName: string | null) {
);
}
type RoomMeetingStatus = components["schemas"]["RoomMeetingStatus"];
export type BulkMeetingStatusMap = Record<string, RoomMeetingStatus>;
export function useRoomsBulkMeetingStatus(roomNames: string[]) {
const { isAuthenticated } = useAuthReady();
return useQuery({
queryKey: ["bulk-meeting-status", roomNames],
queryFn: async (): Promise<BulkMeetingStatusMap> => {
if (roomNames.length === 0) return {};
const { data, error } = await client.POST(
"/v1/rooms/meetings/bulk-status",
{ body: { room_names: roomNames } },
);
if (error || !data) {
throw new Error(
`bulk-status fetch failed: ${JSON.stringify(error ?? "no data")}`,
);
}
return data;
},
enabled: roomNames.length > 0 && isAuthenticated,
});
}
export function useRoomGetMeeting(
roomName: string | null,
meetingId: MeetingId | null,