/**
* Tests for PluginProvider context.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, waitFor, act } from '@testing-library/react'
import { PluginProvider, PluginContext } from './plugin-context'
import type { PluginContextValue } from './plugin-context'
import { getPlugins } from '@/lib/api/client'
import { useAuth } from '@/hooks/use-auth'
import { useContext, useEffect, useRef } from 'react'
import type { Plugin } from '@/lib/api/types'
vi.mock('@/lib/api/client', () => ({
getPlugins: vi.fn(),
}))
vi.mock('@/hooks/use-auth', () => ({
useAuth: vi.fn(),
}))
// Mutable container to capture context without TS control-flow narrowing
interface ContextRef {
current: PluginContextValue | null
}
function createContextRef(): ContextRef {
return { current: null }
}
// Test consumer component that renders plugin context values
function PluginConsumer({ ctxRef }: { ctxRef: ContextRef }) {
const context = useContext(PluginContext)
const ref = useRef(ctxRef)
useEffect(() => {
ref.current.current = context
})
return (
{String(context?.isLoading)}
{context?.plugins.length ?? 0}
)
}
const mockPlugins: Plugin[] = [
{
id: '1',
name: 'analytics',
displayName: 'Analytics',
version: '1.0.0',
description: 'Analytics plugin',
source: 'core',
enabled: true,
category: 'analytics',
dependencies: [],
dependents: [],
settingsSchema: {},
settings: { trackPageViews: true, sampleRate: 0.5 },
installedAt: '2026-01-01T00:00:00Z',
},
{
id: '2',
name: 'spam-filter',
displayName: 'Spam Filter',
version: '2.1.0',
description: 'Spam filtering plugin',
source: 'official',
enabled: false,
category: 'moderation',
dependencies: [],
dependents: [],
settingsSchema: {},
settings: { threshold: 0.8 },
installedAt: '2026-01-15T00:00:00Z',
},
]
function mockAuthUnauthenticated() {
vi.mocked(useAuth).mockReturnValue({
user: null,
isAuthenticated: false,
isLoading: false,
crossPostScopesGranted: false,
getAccessToken: () => null,
login: vi.fn(),
logout: vi.fn(),
setSessionFromCallback: vi.fn(),
requestCrossPostAuth: vi.fn(),
authFetch: vi.fn(),
} as ReturnType)
}
function mockAuthAuthenticated() {
vi.mocked(useAuth).mockReturnValue({
user: {
did: 'did:plc:test',
handle: 'test.bsky.social',
displayName: 'Test User',
avatarUrl: null,
role: 'user',
},
isAuthenticated: true,
isLoading: false,
crossPostScopesGranted: false,
getAccessToken: () => 'mock-token',
login: vi.fn(),
logout: vi.fn(),
setSessionFromCallback: vi.fn(),
requestCrossPostAuth: vi.fn(),
authFetch: vi.fn(),
} as ReturnType)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('PluginProvider', () => {
it('provides empty plugins array when not authenticated', async () => {
mockAuthUnauthenticated()
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.plugins).toEqual([])
expect(getPlugins).not.toHaveBeenCalled()
})
it('fetches plugins when authenticated', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(getPlugins).toHaveBeenCalledWith('mock-token')
expect(ctxRef.current?.plugins).toHaveLength(2)
expect(ctxRef.current?.plugins[0]!.name).toBe('analytics')
expect(ctxRef.current?.plugins[1]!.name).toBe('spam-filter')
})
it('isPluginEnabled returns true for enabled plugin', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.isPluginEnabled('analytics')).toBe(true)
})
it('isPluginEnabled returns false for disabled plugin', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.isPluginEnabled('spam-filter')).toBe(false)
})
it('isPluginEnabled returns false for unknown plugin', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.isPluginEnabled('nonexistent')).toBe(false)
})
it('getPluginSettings returns settings for installed plugin', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
const settings = ctxRef.current?.getPluginSettings('analytics')
expect(settings).toEqual({ trackPageViews: true, sampleRate: 0.5 })
})
it('getPluginSettings returns a copy, not the original object', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
const settings1 = ctxRef.current?.getPluginSettings('analytics')
const settings2 = ctxRef.current?.getPluginSettings('analytics')
expect(settings1).toEqual(settings2)
expect(settings1).not.toBe(settings2)
})
it('getPluginSettings returns null for unknown plugin', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins).mockResolvedValue({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.getPluginSettings('nonexistent')).toBeNull()
})
it('refreshPlugins triggers re-fetch', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins)
.mockResolvedValueOnce({ plugins: [mockPlugins[0]!] })
.mockResolvedValueOnce({ plugins: mockPlugins })
const ctxRef = createContextRef()
render(
)
// Wait for initial fetch
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.plugins).toHaveLength(1)
// Trigger refresh
await act(async () => {
await ctxRef.current?.refreshPlugins()
})
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(getPlugins).toHaveBeenCalledTimes(2)
expect(ctxRef.current?.plugins).toHaveLength(2)
})
it('keeps existing plugins on fetch error', async () => {
mockAuthAuthenticated()
vi.mocked(getPlugins)
.mockResolvedValueOnce({ plugins: mockPlugins })
.mockRejectedValueOnce(new Error('Network error'))
const ctxRef = createContextRef()
render(
)
// Wait for initial fetch
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
expect(ctxRef.current?.plugins).toHaveLength(2)
// Trigger refresh that fails
await act(async () => {
await ctxRef.current?.refreshPlugins()
})
await waitFor(() => {
expect(ctxRef.current?.isLoading).toBe(false)
})
// Plugins should be preserved
expect(ctxRef.current?.plugins).toHaveLength(2)
})
it('does not fetch while auth is still loading', () => {
vi.mocked(useAuth).mockReturnValue({
user: null,
isAuthenticated: false,
isLoading: true,
crossPostScopesGranted: false,
getAccessToken: () => null,
login: vi.fn(),
logout: vi.fn(),
setSessionFromCallback: vi.fn(),
requestCrossPostAuth: vi.fn(),
authFetch: vi.fn(),
} as ReturnType)
const ctxRef = createContextRef()
render(
)
expect(getPlugins).not.toHaveBeenCalled()
expect(ctxRef.current?.isLoading).toBe(true)
})
})