Skip to content

Commit c9bd667

Browse files
committed
feat: add frontend test infrastructure and fix CI workflow
- Fix pnpm version mismatch in test_next_server.yml (8 → auto-detect 10) - Add concurrency group to cancel stale CI runs - Remove redundant setup-node step - Update jest.config.js for jsdom + tsx support - Add meetingStatusBatcher integration test (3 tests) - Extract createMeetingStatusBatcher factory for testability
1 parent 1292905 commit c9bd667

6 files changed

Lines changed: 1042 additions & 42 deletions

File tree

.github/workflows/test_next_server.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ on:
1313
jobs:
1414
test-next-server:
1515
runs-on: ubuntu-latest
16+
concurrency:
17+
group: test-next-server-${{ github.ref }}
18+
cancel-in-progress: true
1619

1720
defaults:
1821
run:
@@ -21,17 +24,10 @@ jobs:
2124
steps:
2225
- uses: actions/checkout@v4
2326

24-
- name: Setup Node.js
25-
uses: actions/setup-node@v4
26-
with:
27-
node-version: '20'
28-
2927
- name: Install pnpm
3028
uses: pnpm/action-setup@v4
31-
with:
32-
version: 8
3329

34-
- name: Setup Node.js cache
30+
- name: Setup Node.js
3531
uses: actions/setup-node@v4
3632
with:
3733
node-version: '20'
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import "@testing-library/jest-dom";
2+
3+
// --- Module mocks (hoisted before imports) ---
4+
5+
jest.mock("../apiClient", () => ({
6+
client: {
7+
GET: jest.fn(),
8+
POST: jest.fn(),
9+
PUT: jest.fn(),
10+
PATCH: jest.fn(),
11+
DELETE: jest.fn(),
12+
use: jest.fn(),
13+
},
14+
$api: {
15+
useQuery: jest.fn(),
16+
useMutation: jest.fn(),
17+
},
18+
API_URL: "http://test",
19+
WEBSOCKET_URL: "ws://test",
20+
configureApiAuth: jest.fn(),
21+
}));
22+
23+
jest.mock("../AuthProvider", () => ({
24+
useAuth: () => ({
25+
status: "authenticated" as const,
26+
accessToken: "test-token",
27+
accessTokenExpires: Date.now() + 3600000,
28+
user: { id: "user1", name: "Test User" },
29+
update: jest.fn(),
30+
signIn: jest.fn(),
31+
signOut: jest.fn(),
32+
lastUserId: "user1",
33+
}),
34+
}));
35+
36+
// Recreate the batcher with a 0ms window. setTimeout(fn, 0) defers to the next
37+
// macrotask boundary — after all synchronous React rendering completes. All
38+
// useQuery queryFns fire within the same macrotask, so they all queue into one
39+
// batch before the timer fires. This is deterministic and avoids fake timers.
40+
jest.mock("../meetingStatusBatcher", () => {
41+
const actual = jest.requireActual("../meetingStatusBatcher");
42+
return {
43+
...actual,
44+
meetingStatusBatcher: actual.createMeetingStatusBatcher(0),
45+
};
46+
});
47+
48+
// --- Imports (after mocks) ---
49+
50+
import React from "react";
51+
import { render, waitFor, screen } from "@testing-library/react";
52+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
53+
import { useRoomActiveMeetings, useRoomUpcomingMeetings } from "../apiHooks";
54+
import { client } from "../apiClient";
55+
import { ErrorProvider } from "../../(errors)/errorContext";
56+
57+
const mockClient = client as { POST: jest.Mock };
58+
59+
// --- Helpers ---
60+
61+
function mockBulkStatusEndpoint(
62+
roomData?: Record<
63+
string,
64+
{ active_meetings: unknown[]; upcoming_events: unknown[] }
65+
>,
66+
) {
67+
mockClient.POST.mockImplementation(
68+
async (_path: string, options: { body: { room_names: string[] } }) => {
69+
const roomNames: string[] = options.body.room_names;
70+
const data = roomData
71+
? Object.fromEntries(
72+
roomNames.map((name) => [
73+
name,
74+
roomData[name] ?? { active_meetings: [], upcoming_events: [] },
75+
]),
76+
)
77+
: Object.fromEntries(
78+
roomNames.map((name) => [
79+
name,
80+
{ active_meetings: [], upcoming_events: [] },
81+
]),
82+
);
83+
return { data, error: undefined, response: {} };
84+
},
85+
);
86+
}
87+
88+
// --- Test component: renders N room cards, each using both hooks ---
89+
90+
function RoomCard({ roomName }: { roomName: string }) {
91+
const active = useRoomActiveMeetings(roomName);
92+
const upcoming = useRoomUpcomingMeetings(roomName);
93+
94+
if (active.isLoading || upcoming.isLoading) {
95+
return <div data-testid={`room-${roomName}`}>loading</div>;
96+
}
97+
98+
return (
99+
<div data-testid={`room-${roomName}`}>
100+
{active.data?.length ?? 0} active, {upcoming.data?.length ?? 0} upcoming
101+
</div>
102+
);
103+
}
104+
105+
function RoomList({ roomNames }: { roomNames: string[] }) {
106+
return (
107+
<>
108+
{roomNames.map((name) => (
109+
<RoomCard key={name} roomName={name} />
110+
))}
111+
</>
112+
);
113+
}
114+
115+
function createWrapper() {
116+
const queryClient = new QueryClient({
117+
defaultOptions: {
118+
queries: { retry: false },
119+
},
120+
});
121+
return function Wrapper({ children }: { children: React.ReactNode }) {
122+
return (
123+
<QueryClientProvider client={queryClient}>
124+
<ErrorProvider>{children}</ErrorProvider>
125+
</QueryClientProvider>
126+
);
127+
};
128+
}
129+
130+
// --- Tests ---
131+
132+
describe("meeting status batcher integration", () => {
133+
afterEach(() => jest.clearAllMocks());
134+
135+
it("batches multiple room queries into a single POST request", async () => {
136+
const rooms = Array.from({ length: 10 }, (_, i) => `room-${i}`);
137+
138+
mockBulkStatusEndpoint();
139+
140+
render(<RoomList roomNames={rooms} />, { wrapper: createWrapper() });
141+
142+
await waitFor(() => {
143+
for (const name of rooms) {
144+
expect(screen.getByTestId(`room-${name}`)).toHaveTextContent(
145+
"0 active, 0 upcoming",
146+
);
147+
}
148+
});
149+
150+
const postCalls = mockClient.POST.mock.calls.filter(
151+
([path]: [string]) => path === "/v1/rooms/meetings/bulk-status",
152+
);
153+
154+
// Without batching this would be 20 calls (2 hooks x 10 rooms).
155+
// With the 200ms test window, all queries land in one batch → exactly 1 POST.
156+
expect(postCalls).toHaveLength(1);
157+
158+
// The single call should contain all 10 rooms (deduplicated)
159+
const requestedRooms: string[] = postCalls[0][1].body.room_names;
160+
for (const name of rooms) {
161+
expect(requestedRooms).toContain(name);
162+
}
163+
});
164+
165+
it("batcher fetcher returns room-specific data", async () => {
166+
const {
167+
meetingStatusBatcher: batcher,
168+
} = require("../meetingStatusBatcher");
169+
170+
mockBulkStatusEndpoint({
171+
"room-a": {
172+
active_meetings: [{ id: "m1", room_name: "room-a" }],
173+
upcoming_events: [],
174+
},
175+
"room-b": {
176+
active_meetings: [],
177+
upcoming_events: [{ id: "e1", title: "Standup" }],
178+
},
179+
});
180+
181+
const [resultA, resultB] = await Promise.all([
182+
batcher.fetch("room-a"),
183+
batcher.fetch("room-b"),
184+
]);
185+
186+
expect(mockClient.POST).toHaveBeenCalledTimes(1);
187+
expect(resultA.active_meetings).toEqual([
188+
{ id: "m1", room_name: "room-a" },
189+
]);
190+
expect(resultA.upcoming_events).toEqual([]);
191+
expect(resultB.active_meetings).toEqual([]);
192+
expect(resultB.upcoming_events).toEqual([{ id: "e1", title: "Standup" }]);
193+
});
194+
195+
it("renders room-specific meeting data through hooks", async () => {
196+
mockBulkStatusEndpoint({
197+
"room-a": {
198+
active_meetings: [{ id: "m1", room_name: "room-a" }],
199+
upcoming_events: [],
200+
},
201+
"room-b": {
202+
active_meetings: [],
203+
upcoming_events: [{ id: "e1", title: "Standup" }],
204+
},
205+
});
206+
207+
render(<RoomList roomNames={["room-a", "room-b"]} />, {
208+
wrapper: createWrapper(),
209+
});
210+
211+
await waitFor(() => {
212+
expect(screen.getByTestId("room-room-a")).toHaveTextContent(
213+
"1 active, 0 upcoming",
214+
);
215+
expect(screen.getByTestId("room-room-b")).toHaveTextContent(
216+
"0 active, 1 upcoming",
217+
);
218+
});
219+
});
220+
});

www/app/lib/meetingStatusBatcher.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@ type MeetingStatusResult = {
88
upcoming_events: components["schemas"]["CalendarEventResponse"][];
99
};
1010

11-
export const meetingStatusBatcher = create({
12-
fetcher: async (roomNames: string[]): Promise<MeetingStatusResult[]> => {
13-
const unique = [...new Set(roomNames)];
14-
const { data } = await client.POST("/v1/rooms/meetings/bulk-status", {
15-
body: { room_names: unique },
16-
});
17-
return roomNames.map((name) => ({
18-
roomName: name,
19-
active_meetings: data?.[name]?.active_meetings ?? [],
20-
upcoming_events: data?.[name]?.upcoming_events ?? [],
21-
}));
22-
},
23-
resolver: keyResolver("roomName"),
24-
scheduler: windowScheduler(10),
25-
});
11+
const BATCH_WINDOW_MS = 10;
12+
13+
export function createMeetingStatusBatcher(windowMs: number = BATCH_WINDOW_MS) {
14+
return create({
15+
fetcher: async (roomNames: string[]): Promise<MeetingStatusResult[]> => {
16+
const unique = [...new Set(roomNames)];
17+
const { data } = await client.POST("/v1/rooms/meetings/bulk-status", {
18+
body: { room_names: unique },
19+
});
20+
return roomNames.map((name) => ({
21+
roomName: name,
22+
active_meetings: data?.[name]?.active_meetings ?? [],
23+
upcoming_events: data?.[name]?.upcoming_events ?? [],
24+
}));
25+
},
26+
resolver: keyResolver("roomName"),
27+
scheduler: windowScheduler(windowMs),
28+
});
29+
}
30+
31+
export const meetingStatusBatcher = createMeetingStatusBatcher();

www/jest.config.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
module.exports = {
2-
preset: "ts-jest",
3-
testEnvironment: "node",
2+
testEnvironment: "jest-environment-jsdom",
43
roots: ["<rootDir>/app"],
5-
testMatch: ["**/__tests__/**/*.test.ts"],
6-
collectCoverage: true,
7-
collectCoverageFrom: ["app/**/*.ts", "!app/**/*.d.ts"],
4+
testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"],
5+
collectCoverage: false,
6+
transform: {
7+
"^.+\\.[jt]sx?$": [
8+
"ts-jest",
9+
{
10+
tsconfig: {
11+
jsx: "react-jsx",
12+
module: "esnext",
13+
moduleResolution: "bundler",
14+
esModuleInterop: true,
15+
strict: false,
16+
strictNullChecks: true,
17+
downlevelIteration: true,
18+
lib: ["dom", "dom.iterable", "esnext"],
19+
},
20+
},
21+
],
22+
},
823
};

www/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,13 @@
6262
"author": "Andreas <andreas@monadical.com>",
6363
"license": "All Rights Reserved",
6464
"devDependencies": {
65+
"@testing-library/dom": "^10.4.1",
66+
"@testing-library/jest-dom": "^6.9.1",
67+
"@testing-library/react": "^16.3.2",
6568
"@types/jest": "^30.0.0",
6669
"@types/react": "18.2.20",
6770
"jest": "^30.1.3",
71+
"jest-environment-jsdom": "^30.2.0",
6872
"openapi-typescript": "^7.9.1",
6973
"prettier": "^3.0.0",
7074
"ts-jest": "^29.4.1"

0 commit comments

Comments
 (0)