Barazo default frontend
barazo.forum
1/**
2 * Tests for PluginProvider context.
3 */
4
5import { describe, it, expect, vi, beforeEach } from 'vitest'
6import { render, waitFor, act } from '@testing-library/react'
7import { PluginProvider, PluginContext } from './plugin-context'
8import type { PluginContextValue } from './plugin-context'
9import { getPlugins } from '@/lib/api/client'
10import { useAuth } from '@/hooks/use-auth'
11import { useContext, useEffect, useRef } from 'react'
12import type { Plugin } from '@/lib/api/types'
13
14vi.mock('@/lib/api/client', () => ({
15 getPlugins: vi.fn(),
16}))
17
18vi.mock('@/hooks/use-auth', () => ({
19 useAuth: vi.fn(),
20}))
21
22// Mutable container to capture context without TS control-flow narrowing
23interface ContextRef {
24 current: PluginContextValue | null
25}
26
27function createContextRef(): ContextRef {
28 return { current: null }
29}
30
31// Test consumer component that renders plugin context values
32function PluginConsumer({ ctxRef }: { ctxRef: ContextRef }) {
33 const context = useContext(PluginContext)
34 const ref = useRef(ctxRef)
35 useEffect(() => {
36 ref.current.current = context
37 })
38 return (
39 <div>
40 <span data-testid="loading">{String(context?.isLoading)}</span>
41 <span data-testid="plugin-count">{context?.plugins.length ?? 0}</span>
42 </div>
43 )
44}
45
46const mockPlugins: Plugin[] = [
47 {
48 id: '1',
49 name: 'analytics',
50 displayName: 'Analytics',
51 version: '1.0.0',
52 description: 'Analytics plugin',
53 source: 'core',
54 enabled: true,
55 category: 'analytics',
56 dependencies: [],
57 dependents: [],
58 settingsSchema: {},
59 settings: { trackPageViews: true, sampleRate: 0.5 },
60 installedAt: '2026-01-01T00:00:00Z',
61 },
62 {
63 id: '2',
64 name: 'spam-filter',
65 displayName: 'Spam Filter',
66 version: '2.1.0',
67 description: 'Spam filtering plugin',
68 source: 'official',
69 enabled: false,
70 category: 'moderation',
71 dependencies: [],
72 dependents: [],
73 settingsSchema: {},
74 settings: { threshold: 0.8 },
75 installedAt: '2026-01-15T00:00:00Z',
76 },
77]
78
79function mockAuthUnauthenticated() {
80 vi.mocked(useAuth).mockReturnValue({
81 user: null,
82 isAuthenticated: false,
83 isLoading: false,
84 crossPostScopesGranted: false,
85 getAccessToken: () => null,
86 login: vi.fn(),
87 logout: vi.fn(),
88 setSessionFromCallback: vi.fn(),
89 requestCrossPostAuth: vi.fn(),
90 authFetch: vi.fn(),
91 } as ReturnType<typeof useAuth>)
92}
93
94function mockAuthAuthenticated() {
95 vi.mocked(useAuth).mockReturnValue({
96 user: {
97 did: 'did:plc:test',
98 handle: 'test.bsky.social',
99 displayName: 'Test User',
100 avatarUrl: null,
101 role: 'user',
102 },
103 isAuthenticated: true,
104 isLoading: false,
105 crossPostScopesGranted: false,
106 getAccessToken: () => 'mock-token',
107 login: vi.fn(),
108 logout: vi.fn(),
109 setSessionFromCallback: vi.fn(),
110 requestCrossPostAuth: vi.fn(),
111 authFetch: vi.fn(),
112 } as ReturnType<typeof useAuth>)
113}
114
115beforeEach(() => {
116 vi.clearAllMocks()
117})
118
119describe('PluginProvider', () => {
120 it('provides empty plugins array when not authenticated', async () => {
121 mockAuthUnauthenticated()
122
123 const ctxRef = createContextRef()
124 render(
125 <PluginProvider>
126 <PluginConsumer ctxRef={ctxRef} />
127 </PluginProvider>
128 )
129
130 await waitFor(() => {
131 expect(ctxRef.current?.isLoading).toBe(false)
132 })
133
134 expect(ctxRef.current?.plugins).toEqual([])
135 expect(getPlugins).not.toHaveBeenCalled()
136 })
137
138 it('fetches plugins when authenticated', async () => {
139 mockAuthAuthenticated()
140 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
141
142 const ctxRef = createContextRef()
143 render(
144 <PluginProvider>
145 <PluginConsumer ctxRef={ctxRef} />
146 </PluginProvider>
147 )
148
149 await waitFor(() => {
150 expect(ctxRef.current?.isLoading).toBe(false)
151 })
152
153 expect(getPlugins).toHaveBeenCalledWith('mock-token')
154 expect(ctxRef.current?.plugins).toHaveLength(2)
155 expect(ctxRef.current?.plugins[0]!.name).toBe('analytics')
156 expect(ctxRef.current?.plugins[1]!.name).toBe('spam-filter')
157 })
158
159 it('isPluginEnabled returns true for enabled plugin', async () => {
160 mockAuthAuthenticated()
161 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
162
163 const ctxRef = createContextRef()
164 render(
165 <PluginProvider>
166 <PluginConsumer ctxRef={ctxRef} />
167 </PluginProvider>
168 )
169
170 await waitFor(() => {
171 expect(ctxRef.current?.isLoading).toBe(false)
172 })
173
174 expect(ctxRef.current?.isPluginEnabled('analytics')).toBe(true)
175 })
176
177 it('isPluginEnabled returns false for disabled plugin', async () => {
178 mockAuthAuthenticated()
179 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
180
181 const ctxRef = createContextRef()
182 render(
183 <PluginProvider>
184 <PluginConsumer ctxRef={ctxRef} />
185 </PluginProvider>
186 )
187
188 await waitFor(() => {
189 expect(ctxRef.current?.isLoading).toBe(false)
190 })
191
192 expect(ctxRef.current?.isPluginEnabled('spam-filter')).toBe(false)
193 })
194
195 it('isPluginEnabled returns false for unknown plugin', async () => {
196 mockAuthAuthenticated()
197 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
198
199 const ctxRef = createContextRef()
200 render(
201 <PluginProvider>
202 <PluginConsumer ctxRef={ctxRef} />
203 </PluginProvider>
204 )
205
206 await waitFor(() => {
207 expect(ctxRef.current?.isLoading).toBe(false)
208 })
209
210 expect(ctxRef.current?.isPluginEnabled('nonexistent')).toBe(false)
211 })
212
213 it('getPluginSettings returns settings for installed plugin', async () => {
214 mockAuthAuthenticated()
215 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
216
217 const ctxRef = createContextRef()
218 render(
219 <PluginProvider>
220 <PluginConsumer ctxRef={ctxRef} />
221 </PluginProvider>
222 )
223
224 await waitFor(() => {
225 expect(ctxRef.current?.isLoading).toBe(false)
226 })
227
228 const settings = ctxRef.current?.getPluginSettings('analytics')
229 expect(settings).toEqual({ trackPageViews: true, sampleRate: 0.5 })
230 })
231
232 it('getPluginSettings returns a copy, not the original object', async () => {
233 mockAuthAuthenticated()
234 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
235
236 const ctxRef = createContextRef()
237 render(
238 <PluginProvider>
239 <PluginConsumer ctxRef={ctxRef} />
240 </PluginProvider>
241 )
242
243 await waitFor(() => {
244 expect(ctxRef.current?.isLoading).toBe(false)
245 })
246
247 const settings1 = ctxRef.current?.getPluginSettings('analytics')
248 const settings2 = ctxRef.current?.getPluginSettings('analytics')
249 expect(settings1).toEqual(settings2)
250 expect(settings1).not.toBe(settings2)
251 })
252
253 it('getPluginSettings returns null for unknown plugin', async () => {
254 mockAuthAuthenticated()
255 vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
256
257 const ctxRef = createContextRef()
258 render(
259 <PluginProvider>
260 <PluginConsumer ctxRef={ctxRef} />
261 </PluginProvider>
262 )
263
264 await waitFor(() => {
265 expect(ctxRef.current?.isLoading).toBe(false)
266 })
267
268 expect(ctxRef.current?.getPluginSettings('nonexistent')).toBeNull()
269 })
270
271 it('refreshPlugins triggers re-fetch', async () => {
272 mockAuthAuthenticated()
273 vi.mocked(getPlugins)
274 .mockResolvedValueOnce({ plugins: [mockPlugins[0]!] })
275 .mockResolvedValueOnce({ plugins: mockPlugins })
276
277 const ctxRef = createContextRef()
278 render(
279 <PluginProvider>
280 <PluginConsumer ctxRef={ctxRef} />
281 </PluginProvider>
282 )
283
284 // Wait for initial fetch
285 await waitFor(() => {
286 expect(ctxRef.current?.isLoading).toBe(false)
287 })
288 expect(ctxRef.current?.plugins).toHaveLength(1)
289
290 // Trigger refresh
291 await act(async () => {
292 await ctxRef.current?.refreshPlugins()
293 })
294
295 await waitFor(() => {
296 expect(ctxRef.current?.isLoading).toBe(false)
297 })
298
299 expect(getPlugins).toHaveBeenCalledTimes(2)
300 expect(ctxRef.current?.plugins).toHaveLength(2)
301 })
302
303 it('keeps existing plugins on fetch error', async () => {
304 mockAuthAuthenticated()
305 vi.mocked(getPlugins)
306 .mockResolvedValueOnce({ plugins: mockPlugins })
307 .mockRejectedValueOnce(new Error('Network error'))
308
309 const ctxRef = createContextRef()
310 render(
311 <PluginProvider>
312 <PluginConsumer ctxRef={ctxRef} />
313 </PluginProvider>
314 )
315
316 // Wait for initial fetch
317 await waitFor(() => {
318 expect(ctxRef.current?.isLoading).toBe(false)
319 })
320 expect(ctxRef.current?.plugins).toHaveLength(2)
321
322 // Trigger refresh that fails
323 await act(async () => {
324 await ctxRef.current?.refreshPlugins()
325 })
326
327 await waitFor(() => {
328 expect(ctxRef.current?.isLoading).toBe(false)
329 })
330
331 // Plugins should be preserved
332 expect(ctxRef.current?.plugins).toHaveLength(2)
333 })
334
335 it('does not fetch while auth is still loading', () => {
336 vi.mocked(useAuth).mockReturnValue({
337 user: null,
338 isAuthenticated: false,
339 isLoading: true,
340 crossPostScopesGranted: false,
341 getAccessToken: () => null,
342 login: vi.fn(),
343 logout: vi.fn(),
344 setSessionFromCallback: vi.fn(),
345 requestCrossPostAuth: vi.fn(),
346 authFetch: vi.fn(),
347 } as ReturnType<typeof useAuth>)
348
349 const ctxRef = createContextRef()
350 render(
351 <PluginProvider>
352 <PluginConsumer ctxRef={ctxRef} />
353 </PluginProvider>
354 )
355
356 expect(getPlugins).not.toHaveBeenCalled()
357 expect(ctxRef.current?.isLoading).toBe(true)
358 })
359})