Barazo AppView backend
barazo.forum
1import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'
2import Fastify from 'fastify'
3import type { FastifyInstance } from 'fastify'
4import type { Env } from '../../../src/config/env.js'
5import type { RequestUser } from '../../../src/auth/middleware.js'
6import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js'
7
8import { adminPluginRoutes } from '../../../src/routes/admin-plugins.js'
9
10// ---------------------------------------------------------------------------
11// Mock env
12// ---------------------------------------------------------------------------
13
14const mockEnv = {
15 HOSTING_MODE: 'selfhosted',
16} as Env
17
18// ---------------------------------------------------------------------------
19// Test constants
20// ---------------------------------------------------------------------------
21
22const ADMIN_DID = 'did:plc:admin999'
23
24const ADMIN_USER: RequestUser = {
25 did: ADMIN_DID,
26 handle: 'admin.bsky.social',
27 sid: 'a'.repeat(64),
28}
29
30// ---------------------------------------------------------------------------
31// Mock plugin fixture
32// ---------------------------------------------------------------------------
33
34const MOCK_PLUGIN_ROW = {
35 id: '550e8400-e29b-41d4-a716-446655440000',
36 name: '@barazo/plugin-test',
37 displayName: 'Test Plugin',
38 version: '1.0.0',
39 description: 'A test plugin',
40 source: 'core' as const,
41 category: 'social',
42 enabled: false,
43 manifestJson: { name: '@barazo/plugin-test', settings: {}, dependencies: [] },
44 installedAt: new Date('2026-01-01'),
45 updatedAt: new Date('2026-01-01'),
46}
47
48// ---------------------------------------------------------------------------
49// Mock DB
50// ---------------------------------------------------------------------------
51
52const mockDb = createMockDb()
53
54let selectChain: DbChain
55let updateChain: DbChain
56let deleteChain: DbChain
57let insertChain: DbChain
58
59function resetAllDbMocks(): void {
60 selectChain = createChainableProxy([])
61 updateChain = createChainableProxy([])
62 deleteChain = createChainableProxy()
63 insertChain = createChainableProxy()
64 mockDb.select.mockReturnValue(selectChain)
65 mockDb.update.mockReturnValue(updateChain)
66 mockDb.delete.mockReturnValue(deleteChain)
67 mockDb.insert.mockReturnValue(insertChain)
68 mockDb.execute.mockReset()
69 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction
70 mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise<void>) => {
71 await fn(mockDb)
72 })
73}
74
75// ---------------------------------------------------------------------------
76// Mock requireAdmin
77// ---------------------------------------------------------------------------
78
79function createMockRequireAdmin(user?: RequestUser) {
80 return async (
81 request: { user?: RequestUser },
82 reply: { sent: boolean; status: (code: number) => { send: (body: unknown) => Promise<void> } }
83 ) => {
84 if (!user) {
85 await reply.status(401).send({ error: 'Authentication required' })
86 return
87 }
88 request.user = user
89 }
90}
91
92// ---------------------------------------------------------------------------
93// Build test app
94// ---------------------------------------------------------------------------
95
96async function buildTestApp(user?: RequestUser): Promise<FastifyInstance> {
97 const app = Fastify({ logger: false })
98
99 const requireAdmin = createMockRequireAdmin(user)
100
101 app.decorate('db', mockDb as never)
102 app.decorate('env', mockEnv)
103 app.decorate('requireAdmin', requireAdmin as never)
104 app.decorate('cache', {} as never)
105 app.decorate('oauthClient', {} as never)
106 app.decorate('loadedPlugins', new Map() as never)
107 app.decorate('enabledPlugins', new Set() as never)
108 app.decorateRequest('user', undefined as RequestUser | undefined)
109
110 await app.register(adminPluginRoutes())
111 await app.ready()
112
113 return app
114}
115
116// ===========================================================================
117// Test suite
118// ===========================================================================
119
120describe('admin plugin routes', () => {
121 // =========================================================================
122 // GET /api/plugins
123 // =========================================================================
124
125 describe('GET /api/plugins', () => {
126 let app: FastifyInstance
127
128 beforeAll(async () => {
129 app = await buildTestApp(ADMIN_USER)
130 })
131
132 afterAll(async () => {
133 await app.close()
134 })
135
136 beforeEach(() => {
137 vi.clearAllMocks()
138 resetAllDbMocks()
139 })
140
141 it('returns list of plugins (200)', async () => {
142 // Routes call `await db.select().from(plugins)` (no .where()),
143 // so from() must return a thenable that resolves to the mock data.
144 const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW])
145 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain
146 pluginSelectChain.from.mockImplementation(() => ({
147 ...pluginSelectChain,
148 then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) =>
149 Promise.resolve([MOCK_PLUGIN_ROW]).then(resolve, reject),
150 }))
151
152 const settingsSelectChain = createChainableProxy([])
153 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain
154 settingsSelectChain.from.mockImplementation(() => ({
155 ...settingsSelectChain,
156 then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) =>
157 Promise.resolve([]).then(resolve, reject),
158 }))
159
160 mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(settingsSelectChain)
161
162 const response = await app.inject({
163 method: 'GET',
164 url: '/api/plugins',
165 })
166
167 expect(response.statusCode).toBe(200)
168 const body = response.json<{ plugins: unknown[] }>()
169 expect(body.plugins).toHaveLength(1)
170 expect(body.plugins[0]).toMatchObject({
171 id: MOCK_PLUGIN_ROW.id,
172 name: MOCK_PLUGIN_ROW.name,
173 displayName: MOCK_PLUGIN_ROW.displayName,
174 })
175 })
176 })
177
178 // =========================================================================
179 // GET /api/plugins/:id
180 // =========================================================================
181
182 describe('GET /api/plugins/:id', () => {
183 let app: FastifyInstance
184
185 beforeAll(async () => {
186 app = await buildTestApp(ADMIN_USER)
187 })
188
189 afterAll(async () => {
190 await app.close()
191 })
192
193 beforeEach(() => {
194 vi.clearAllMocks()
195 resetAllDbMocks()
196 })
197
198 it('returns 404 when plugin not found', async () => {
199 const pluginSelectChain = createChainableProxy([])
200 mockDb.select.mockReturnValueOnce(pluginSelectChain)
201
202 const response = await app.inject({
203 method: 'GET',
204 url: '/api/plugins/nonexistent-id',
205 })
206
207 expect(response.statusCode).toBe(404)
208 })
209
210 it('returns plugin details when found (200)', async () => {
211 const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW])
212 const settingsSelectChain = createChainableProxy([])
213
214 mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(settingsSelectChain)
215
216 const response = await app.inject({
217 method: 'GET',
218 url: `/api/plugins/${MOCK_PLUGIN_ROW.id}`,
219 })
220
221 expect(response.statusCode).toBe(200)
222 const body = response.json<{ id: string; name: string }>()
223 expect(body.id).toBe(MOCK_PLUGIN_ROW.id)
224 expect(body.name).toBe(MOCK_PLUGIN_ROW.name)
225 })
226 })
227
228 // =========================================================================
229 // PATCH /api/plugins/:id/enable
230 // =========================================================================
231
232 describe('PATCH /api/plugins/:id/enable', () => {
233 let app: FastifyInstance
234
235 beforeAll(async () => {
236 app = await buildTestApp(ADMIN_USER)
237 })
238
239 afterAll(async () => {
240 await app.close()
241 })
242
243 beforeEach(() => {
244 vi.clearAllMocks()
245 resetAllDbMocks()
246 })
247
248 it('enables a disabled plugin (200)', async () => {
249 const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW])
250 mockDb.select.mockReturnValueOnce(pluginSelectChain)
251
252 const updatedRow = { ...MOCK_PLUGIN_ROW, enabled: true, updatedAt: new Date() }
253 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain
254 updateChain.returning.mockImplementation(() => ({
255 ...updateChain,
256 then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) =>
257 Promise.resolve([updatedRow]).then(resolve, reject),
258 }))
259
260 const response = await app.inject({
261 method: 'PATCH',
262 url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/enable`,
263 })
264
265 expect(response.statusCode).toBe(200)
266 const body = response.json<{ enabled: boolean }>()
267 expect(body.enabled).toBe(true)
268 })
269 })
270
271 // =========================================================================
272 // PATCH /api/plugins/:id/disable
273 // =========================================================================
274
275 describe('PATCH /api/plugins/:id/disable', () => {
276 let app: FastifyInstance
277
278 beforeAll(async () => {
279 app = await buildTestApp(ADMIN_USER)
280 })
281
282 afterAll(async () => {
283 await app.close()
284 })
285
286 beforeEach(() => {
287 vi.clearAllMocks()
288 resetAllDbMocks()
289 })
290
291 it('disables an enabled plugin (200)', async () => {
292 const enabledPlugin = { ...MOCK_PLUGIN_ROW, enabled: true }
293 // First select: db.select().from(plugins).where(...) -- has .where(), default chain works
294 const pluginSelectChain = createChainableProxy([enabledPlugin])
295 // Second select: db.select().from(plugins) -- no .where(), from() must be thenable
296 const allPluginsChain = createChainableProxy([enabledPlugin])
297 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain
298 allPluginsChain.from.mockImplementation(() => ({
299 ...allPluginsChain,
300 then: (resolve: (v: unknown) => void, reject?: (e: unknown) => void) =>
301 Promise.resolve([enabledPlugin]).then(resolve, reject),
302 }))
303
304 mockDb.select.mockReturnValueOnce(pluginSelectChain).mockReturnValueOnce(allPluginsChain)
305
306 const updatedRow = { ...MOCK_PLUGIN_ROW, enabled: false, updatedAt: new Date() }
307 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally thenable mock for Drizzle chain
308 updateChain.returning.mockImplementation(() => ({
309 ...updateChain,
310 then: (resolve: (val: unknown) => void, reject?: (err: unknown) => void) =>
311 Promise.resolve([updatedRow]).then(resolve, reject),
312 }))
313
314 const response = await app.inject({
315 method: 'PATCH',
316 url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/disable`,
317 })
318
319 expect(response.statusCode).toBe(200)
320 const body = response.json<{ enabled: boolean }>()
321 expect(body.enabled).toBe(false)
322 })
323 })
324
325 // =========================================================================
326 // PATCH /api/plugins/:id/settings
327 // =========================================================================
328
329 describe('PATCH /api/plugins/:id/settings', () => {
330 let app: FastifyInstance
331
332 beforeAll(async () => {
333 app = await buildTestApp(ADMIN_USER)
334 })
335
336 afterAll(async () => {
337 await app.close()
338 })
339
340 beforeEach(() => {
341 vi.clearAllMocks()
342 resetAllDbMocks()
343 })
344
345 it('updates settings (200)', async () => {
346 const pluginSelectChain = createChainableProxy([MOCK_PLUGIN_ROW])
347 mockDb.select.mockReturnValueOnce(pluginSelectChain)
348
349 const response = await app.inject({
350 method: 'PATCH',
351 url: `/api/plugins/${MOCK_PLUGIN_ROW.id}/settings`,
352 payload: { enabled: true, threshold: 5 },
353 })
354
355 expect(response.statusCode).toBe(200)
356 const body = response.json<{ success: boolean }>()
357 expect(body.success).toBe(true)
358 })
359 })
360
361 // =========================================================================
362 // DELETE /api/plugins/:id
363 // =========================================================================
364
365 describe('DELETE /api/plugins/:id', () => {
366 let app: FastifyInstance
367
368 beforeAll(async () => {
369 app = await buildTestApp(ADMIN_USER)
370 })
371
372 afterAll(async () => {
373 await app.close()
374 })
375
376 beforeEach(() => {
377 vi.clearAllMocks()
378 resetAllDbMocks()
379 })
380
381 it('returns 404 when plugin not found', async () => {
382 const pluginSelectChain = createChainableProxy([])
383 mockDb.select.mockReturnValueOnce(pluginSelectChain)
384
385 const response = await app.inject({
386 method: 'DELETE',
387 url: '/api/plugins/nonexistent-id',
388 })
389
390 expect(response.statusCode).toBe(404)
391 })
392 })
393})