Skip to content

Commit 9914c82

Browse files
committed
test(mcp): cover OAuth popup double-click guard and reopen-after-settle
1 parent c8d054a commit 9914c82

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { sleep } from '@sim/utils/helpers'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockStartOauth } = vi.hoisted(() => ({ mockStartOauth: vi.fn() }))
11+
12+
vi.mock('@sim/emcn', () => ({
13+
toast: { success: vi.fn(), error: vi.fn() },
14+
}))
15+
16+
vi.mock('@/hooks/queries/mcp', () => ({
17+
useStartMcpOauth: () => ({ mutateAsync: mockStartOauth }),
18+
mcpKeys: {
19+
serversList: (workspaceId: string) => ['mcp', 'servers', workspaceId],
20+
serverToolsList: (workspaceId: string, serverId: string) => [
21+
'mcp',
22+
'server-tools',
23+
workspaceId,
24+
serverId,
25+
],
26+
storedToolsList: (workspaceId: string) => ['mcp', 'stored-tools', workspaceId],
27+
},
28+
}))
29+
30+
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
31+
32+
/**
33+
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`).
34+
* Mounts the hook in a real React 19 root under jsdom, wrapped in a real
35+
* `QueryClientProvider`, so query/mutation lifecycles run exactly as in the app.
36+
*/
37+
function renderHookWithClient<T>(useHook: () => T): { result: () => T; unmount: () => void } {
38+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
39+
const queryClient = new QueryClient({
40+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
41+
})
42+
const container = document.createElement('div')
43+
const root: Root = createRoot(container)
44+
let latest: T
45+
46+
function Probe() {
47+
latest = useHook()
48+
return null
49+
}
50+
51+
function Wrapper({ children }: { children: ReactNode }) {
52+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
53+
}
54+
55+
act(() => {
56+
root.render(
57+
<Wrapper>
58+
<Probe />
59+
</Wrapper>
60+
)
61+
})
62+
63+
return { result: () => latest, unmount: () => act(() => root.unmount()) }
64+
}
65+
66+
async function flush() {
67+
await act(async () => {
68+
for (let i = 0; i < 5; i++) {
69+
await Promise.resolve()
70+
await sleep(0)
71+
}
72+
})
73+
}
74+
75+
describe('useMcpOauthPopup', () => {
76+
beforeEach(() => {
77+
vi.clearAllMocks()
78+
// jsdom has no BroadcastChannel; the hook opens one on mount.
79+
class FakeBroadcastChannel {
80+
onmessage: ((event: MessageEvent) => void) | null = null
81+
constructor(public name: string) {}
82+
postMessage(): void {}
83+
close(): void {}
84+
}
85+
;(globalThis as unknown as { BroadcastChannel: unknown }).BroadcastChannel =
86+
FakeBroadcastChannel
87+
})
88+
89+
afterEach(() => {
90+
vi.restoreAllMocks()
91+
})
92+
93+
it('ignores a concurrent second start for the same server (no double popup)', async () => {
94+
let resolveStart: (value: unknown) => void = () => {}
95+
mockStartOauth.mockImplementation(
96+
() =>
97+
new Promise((res) => {
98+
resolveStart = res
99+
})
100+
)
101+
102+
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
103+
await flush()
104+
105+
// Two clicks before /oauth/start resolves — the guard must collapse them to one request.
106+
await act(async () => {
107+
void hook.result().startOauthForServer('s1')
108+
void hook.result().startOauthForServer('s1')
109+
})
110+
expect(mockStartOauth).toHaveBeenCalledTimes(1)
111+
112+
// Settle the first flow so the guard clears.
113+
await act(async () => {
114+
resolveStart({ status: 'redirect', popup: { closed: false }, state: 'state-1' })
115+
})
116+
await flush()
117+
118+
hook.unmount()
119+
})
120+
121+
it('allows a fresh start after the previous one settles (reopen after abandon)', async () => {
122+
mockStartOauth.mockResolvedValue({ status: 'redirect', popup: { closed: false }, state: 'st' })
123+
124+
const hook = renderHookWithClient(() => useMcpOauthPopup({ workspaceId: 'w1' }))
125+
await flush()
126+
127+
await act(async () => {
128+
await hook.result().startOauthForServer('s1')
129+
})
130+
await flush()
131+
132+
await act(async () => {
133+
await hook.result().startOauthForServer('s1')
134+
})
135+
await flush()
136+
137+
// Both distinct clicks reached the mutation — the guard only blocks concurrent re-entry.
138+
expect(mockStartOauth).toHaveBeenCalledTimes(2)
139+
140+
hook.unmount()
141+
})
142+
})

0 commit comments

Comments
 (0)