Barazo AppView backend
barazo.forum
1import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2import type { FastifyInstance } from 'fastify'
3import type { RegistryPlugin } from '../../../../src/lib/plugins/registry.js'
4import {
5 getRegistryIndex,
6 searchRegistryPlugins,
7 getFeaturedPlugins,
8} from '../../../../src/lib/plugins/registry.js'
9
10// ---------------------------------------------------------------------------
11// Test fixtures
12// ---------------------------------------------------------------------------
13
14function makePlugin(overrides: Partial<RegistryPlugin> = {}): RegistryPlugin {
15 return {
16 name: '@barazo/plugin-test',
17 displayName: 'Test Plugin',
18 description: 'A test plugin for unit tests',
19 version: '1.0.0',
20 source: 'official',
21 category: 'moderation',
22 barazoVersion: '^0.1.0',
23 author: { name: 'Barazo Team' },
24 license: 'MIT',
25 npmUrl: 'https://www.npmjs.com/package/@barazo/plugin-test',
26 approved: true,
27 featured: false,
28 downloads: 100,
29 ...overrides,
30 }
31}
32
33const samplePlugins: RegistryPlugin[] = [
34 makePlugin({
35 name: '@barazo/plugin-polls',
36 displayName: 'Polls',
37 description: 'Add polls to your forum topics',
38 category: 'social',
39 source: 'official',
40 featured: true,
41 downloads: 500,
42 }),
43 makePlugin({
44 name: '@barazo/plugin-spam-filter',
45 displayName: 'Spam Filter',
46 description: 'AI-powered spam detection',
47 category: 'moderation',
48 source: 'official',
49 featured: false,
50 downloads: 1200,
51 }),
52 makePlugin({
53 name: 'community-badges',
54 displayName: 'Community Badges',
55 description: 'Custom badge system for community members',
56 category: 'social',
57 source: 'community',
58 featured: true,
59 downloads: 80,
60 }),
61 makePlugin({
62 name: '@barazo/plugin-analytics',
63 displayName: 'Analytics Dashboard',
64 description: 'Privacy-friendly forum analytics',
65 category: 'admin',
66 source: 'official',
67 featured: false,
68 downloads: 300,
69 }),
70]
71
72// ---------------------------------------------------------------------------
73// searchRegistryPlugins
74// ---------------------------------------------------------------------------
75
76describe('searchRegistryPlugins', () => {
77 it('returns all plugins when no filters are provided', () => {
78 const results = searchRegistryPlugins(samplePlugins, {})
79 expect(results).toHaveLength(4)
80 })
81
82 it('filters by text query matching name', () => {
83 const results = searchRegistryPlugins(samplePlugins, { q: 'polls' })
84 expect(results).toHaveLength(1)
85 expect(results[0]?.name).toBe('@barazo/plugin-polls')
86 })
87
88 it('filters by text query matching displayName', () => {
89 const results = searchRegistryPlugins(samplePlugins, { q: 'Analytics Dashboard' })
90 expect(results).toHaveLength(1)
91 expect(results[0]?.name).toBe('@barazo/plugin-analytics')
92 })
93
94 it('filters by text query matching description', () => {
95 const results = searchRegistryPlugins(samplePlugins, { q: 'spam detection' })
96 expect(results).toHaveLength(1)
97 expect(results[0]?.name).toBe('@barazo/plugin-spam-filter')
98 })
99
100 it('text query is case-insensitive', () => {
101 const results = searchRegistryPlugins(samplePlugins, { q: 'POLLS' })
102 expect(results).toHaveLength(1)
103 expect(results[0]?.name).toBe('@barazo/plugin-polls')
104 })
105
106 it('filters by category', () => {
107 const results = searchRegistryPlugins(samplePlugins, { category: 'social' })
108 expect(results).toHaveLength(2)
109 expect(results.map((p) => p.name)).toContain('@barazo/plugin-polls')
110 expect(results.map((p) => p.name)).toContain('community-badges')
111 })
112
113 it('filters by source', () => {
114 const results = searchRegistryPlugins(samplePlugins, { source: 'community' })
115 expect(results).toHaveLength(1)
116 expect(results[0]?.name).toBe('community-badges')
117 })
118
119 it('combines query and category filters', () => {
120 const results = searchRegistryPlugins(samplePlugins, { q: 'badge', category: 'social' })
121 expect(results).toHaveLength(1)
122 expect(results[0]?.name).toBe('community-badges')
123 })
124
125 it('combines all three filters', () => {
126 const results = searchRegistryPlugins(samplePlugins, {
127 q: 'badge',
128 category: 'social',
129 source: 'community',
130 })
131 expect(results).toHaveLength(1)
132 expect(results[0]?.name).toBe('community-badges')
133 })
134
135 it('returns empty array when no plugins match', () => {
136 const results = searchRegistryPlugins(samplePlugins, { q: 'nonexistent-plugin-xyz' })
137 expect(results).toHaveLength(0)
138 })
139
140 it('returns empty array when category filter has no match', () => {
141 const results = searchRegistryPlugins(samplePlugins, { category: 'nonexistent' })
142 expect(results).toHaveLength(0)
143 })
144})
145
146// ---------------------------------------------------------------------------
147// getFeaturedPlugins
148// ---------------------------------------------------------------------------
149
150describe('getFeaturedPlugins', () => {
151 it('returns only featured plugins', () => {
152 const results = getFeaturedPlugins(samplePlugins)
153 expect(results).toHaveLength(2)
154 expect(results.every((p) => p.featured)).toBe(true)
155 })
156
157 it('returns empty array when no plugins are featured', () => {
158 const unfeatured = samplePlugins.map((p) => ({ ...p, featured: false }))
159 const results = getFeaturedPlugins(unfeatured)
160 expect(results).toHaveLength(0)
161 })
162})
163
164// ---------------------------------------------------------------------------
165// getRegistryIndex
166// ---------------------------------------------------------------------------
167
168describe('getRegistryIndex', () => {
169 const registryData = {
170 version: 1,
171 updatedAt: '2026-03-06T00:00:00Z',
172 plugins: samplePlugins,
173 }
174
175 function createMockApp(cacheValue: string | null = null): FastifyInstance {
176 return {
177 cache: {
178 get: vi.fn().mockResolvedValue(cacheValue),
179 set: vi.fn().mockResolvedValue('OK'),
180 },
181 log: {
182 info: vi.fn(),
183 warn: vi.fn(),
184 error: vi.fn(),
185 debug: vi.fn(),
186 trace: vi.fn(),
187 fatal: vi.fn(),
188 child: vi.fn(),
189 },
190 } as unknown as FastifyInstance
191 }
192
193 let originalFetch: typeof globalThis.fetch
194
195 beforeEach(() => {
196 originalFetch = globalThis.fetch
197 })
198
199 afterEach(() => {
200 globalThis.fetch = originalFetch
201 vi.restoreAllMocks()
202 })
203
204 it('returns plugins from cache when available', async () => {
205 const app = createMockApp(JSON.stringify(registryData))
206
207 const result = await getRegistryIndex(app)
208
209 expect(result).toHaveLength(4)
210 expect(result[0]?.name).toBe('@barazo/plugin-polls')
211 // Should not have called fetch
212 // eslint-disable-next-line @typescript-eslint/unbound-method
213 expect(app.cache.get).toHaveBeenCalledWith('plugin:registry:index')
214 })
215
216 it('fetches from registry when cache is empty', async () => {
217 const app = createMockApp(null)
218
219 globalThis.fetch = vi.fn().mockResolvedValue({
220 ok: true,
221 json: vi.fn().mockResolvedValue(registryData),
222 })
223
224 const result = await getRegistryIndex(app)
225
226 expect(result).toHaveLength(4)
227 expect(globalThis.fetch).toHaveBeenCalledWith(
228 'https://registry.barazo.forum/index.json',
229 expect.objectContaining({ signal: expect.any(AbortSignal) as AbortSignal })
230 )
231 // eslint-disable-next-line @typescript-eslint/unbound-method
232 expect(app.cache.set).toHaveBeenCalledWith(
233 'plugin:registry:index',
234 JSON.stringify(registryData),
235 'EX',
236 3600
237 )
238 })
239
240 it('returns empty array when fetch fails', async () => {
241 const app = createMockApp(null)
242
243 globalThis.fetch = vi.fn().mockResolvedValue({
244 ok: false,
245 status: 503,
246 })
247
248 const result = await getRegistryIndex(app)
249
250 expect(result).toHaveLength(0)
251 expect(app.log.warn).toHaveBeenCalled()
252 })
253
254 it('returns empty array when fetch throws', async () => {
255 const app = createMockApp(null)
256
257 globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
258
259 const result = await getRegistryIndex(app)
260
261 expect(result).toHaveLength(0)
262 expect(app.log.warn).toHaveBeenCalled()
263 })
264
265 it('fetches fresh when cache contains invalid JSON', async () => {
266 const app = createMockApp('not-valid-json')
267
268 globalThis.fetch = vi.fn().mockResolvedValue({
269 ok: true,
270 json: vi.fn().mockResolvedValue(registryData),
271 })
272
273 const result = await getRegistryIndex(app)
274
275 expect(result).toHaveLength(4)
276 expect(globalThis.fetch).toHaveBeenCalled()
277 })
278})