Barazo AppView backend barazo.forum
at main 393 lines 13 kB view raw
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})