From 1def422fb16bdafbd4c00d89a85328e9b7fdfc62 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Fri, 6 Mar 2026 08:29:03 +0100 Subject: [PATCH] feat(plugins): add plugin registry search and featured endpoints (#147) * feat(plugins): add marketplace registry routes (P2.12 M3) Add registry fetch/cache service and two public API routes for browsing and searching plugins from the remote registry at registry.barazo.forum. - src/lib/plugins/registry.ts: getRegistryIndex (fetch + Valkey cache), searchRegistryPlugins, getFeaturedPlugins - GET /api/plugins/registry/search (public, supports q/category/source) - GET /api/plugins/registry/featured (public) - 30 tests covering service logic and route behavior * fix(plugins): suppress unbound-method lint in registry tests * fix(tests): resolve lint error in plugin registry route tests Rewrite vi.mock to avoid importOriginal (consistent-type-imports rule) and fix missing closing parenthesis in mock factory. --- src/lib/plugins/registry.ts | 89 ++++++ src/routes/admin-plugins.ts | 97 ++++++ tests/unit/lib/plugins/registry.test.ts | 278 +++++++++++++++++ tests/unit/routes/plugin-registry.test.ts | 349 ++++++++++++++++++++++ 4 files changed, 813 insertions(+) create mode 100644 src/lib/plugins/registry.ts create mode 100644 tests/unit/lib/plugins/registry.test.ts create mode 100644 tests/unit/routes/plugin-registry.test.ts diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts new file mode 100644 index 0000000..71aeef8 --- /dev/null +++ b/src/lib/plugins/registry.ts @@ -0,0 +1,89 @@ +import type { FastifyInstance } from 'fastify' + +export interface RegistryPlugin { + name: string + displayName: string + description: string + version: string + source: 'core' | 'official' | 'community' | 'experimental' + category: string + barazoVersion: string + author: { name: string; url?: string } + license: string + npmUrl: string + repositoryUrl?: string + approved: boolean + featured: boolean + downloads: number +} + +interface RegistryIndex { + version: number + updatedAt: string + plugins: RegistryPlugin[] +} + +const REGISTRY_URL = 'https://registry.barazo.forum/index.json' +const CACHE_KEY = 'plugin:registry:index' +const CACHE_TTL = 3600 // 1 hour + +export async function getRegistryIndex(app: FastifyInstance): Promise { + const { cache } = app + + const cached = await cache.get(CACHE_KEY) + if (cached) { + try { + const parsed = JSON.parse(cached) as RegistryIndex + return parsed.plugins + } catch { + // Invalid cache entry, fetch fresh + } + } + + try { + const response = await fetch(REGISTRY_URL, { + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) { + app.log.warn({ status: response.status }, 'Failed to fetch plugin registry') + return [] + } + const data = (await response.json()) as RegistryIndex + await cache.set(CACHE_KEY, JSON.stringify(data), 'EX', CACHE_TTL) + return data.plugins + } catch (error) { + app.log.warn({ error }, 'Failed to fetch plugin registry') + return [] + } +} + +export function searchRegistryPlugins( + plugins: RegistryPlugin[], + params: { q?: string | undefined; category?: string | undefined; source?: string | undefined } +): RegistryPlugin[] { + let results = plugins + + if (params.q) { + const query = params.q.toLowerCase() + results = results.filter( + (p) => + p.name.toLowerCase().includes(query) || + p.displayName.toLowerCase().includes(query) || + p.description.toLowerCase().includes(query) + ) + } + + if (params.category) { + results = results.filter((p) => p.category === params.category) + } + + if (params.source) { + results = results.filter((p) => p.source === params.source) + } + + return results +} + +export function getFeaturedPlugins(plugins: RegistryPlugin[]): RegistryPlugin[] { + return plugins.filter((p) => p.featured) +} diff --git a/src/routes/admin-plugins.ts b/src/routes/admin-plugins.ts index 48ffca1..cc3f31c 100644 --- a/src/routes/admin-plugins.ts +++ b/src/routes/admin-plugins.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module' import { promisify } from 'node:util' import type { FastifyPluginCallback } from 'fastify' import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' +import { getRegistryIndex, searchRegistryPlugins, getFeaturedPlugins } from '../lib/plugins/registry.js' import { updatePluginSettingsSchema, installPluginSchema } from '../validation/admin-plugins.js' import { pluginManifestSchema } from '../validation/plugin-manifest.js' import { plugins, pluginSettings } from '../db/schema/plugins.js' @@ -608,6 +609,102 @@ export function adminPluginRoutes(): FastifyPluginCallback { } ) + // ------------------------------------------------------------------- + // Registry routes (public -- no auth required) + // ------------------------------------------------------------------- + + const registryPluginJsonSchema = { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + displayName: { type: 'string' as const }, + description: { type: 'string' as const }, + version: { type: 'string' as const }, + source: { type: 'string' as const }, + category: { type: 'string' as const }, + barazoVersion: { type: 'string' as const }, + author: { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + url: { type: 'string' as const }, + }, + }, + license: { type: 'string' as const }, + npmUrl: { type: 'string' as const }, + repositoryUrl: { type: 'string' as const }, + approved: { type: 'boolean' as const }, + featured: { type: 'boolean' as const }, + downloads: { type: 'number' as const }, + }, + } + + const registryPluginListJsonSchema = { + type: 'object' as const, + properties: { + plugins: { + type: 'array' as const, + items: registryPluginJsonSchema, + }, + }, + } + + // ------------------------------------------------------------------- + // GET /api/plugins/registry/search (public) + // ------------------------------------------------------------------- + + app.get( + '/api/plugins/registry/search', + { + schema: { + tags: ['Plugins'], + summary: 'Search the plugin registry', + querystring: { + type: 'object' as const, + properties: { + q: { type: 'string' as const }, + category: { type: 'string' as const }, + source: { type: 'string' as const }, + }, + }, + response: { + 200: registryPluginListJsonSchema, + }, + }, + }, + async (request) => { + const { q, category, source } = request.query as { + q?: string + category?: string + source?: string + } + const registryPlugins = await getRegistryIndex(app) + const results = searchRegistryPlugins(registryPlugins, { q, category, source }) + return { plugins: results } + } + ) + + // ------------------------------------------------------------------- + // GET /api/plugins/registry/featured (public) + // ------------------------------------------------------------------- + + app.get( + '/api/plugins/registry/featured', + { + schema: { + tags: ['Plugins'], + summary: 'Get featured plugins from the registry', + response: { + 200: registryPluginListJsonSchema, + }, + }, + }, + async () => { + const registryPlugins = await getRegistryIndex(app) + return { plugins: getFeaturedPlugins(registryPlugins) } + } + ) + done() } } diff --git a/tests/unit/lib/plugins/registry.test.ts b/tests/unit/lib/plugins/registry.test.ts new file mode 100644 index 0000000..d3a4b7f --- /dev/null +++ b/tests/unit/lib/plugins/registry.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { FastifyInstance } from 'fastify' +import type { RegistryPlugin } from '../../../../src/lib/plugins/registry.js' +import { + getRegistryIndex, + searchRegistryPlugins, + getFeaturedPlugins, +} from '../../../../src/lib/plugins/registry.js' + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function makePlugin(overrides: Partial = {}): RegistryPlugin { + return { + name: '@barazo/plugin-test', + displayName: 'Test Plugin', + description: 'A test plugin for unit tests', + version: '1.0.0', + source: 'official', + category: 'moderation', + barazoVersion: '^0.1.0', + author: { name: 'Barazo Team' }, + license: 'MIT', + npmUrl: 'https://www.npmjs.com/package/@barazo/plugin-test', + approved: true, + featured: false, + downloads: 100, + ...overrides, + } +} + +const samplePlugins: RegistryPlugin[] = [ + makePlugin({ + name: '@barazo/plugin-polls', + displayName: 'Polls', + description: 'Add polls to your forum topics', + category: 'social', + source: 'official', + featured: true, + downloads: 500, + }), + makePlugin({ + name: '@barazo/plugin-spam-filter', + displayName: 'Spam Filter', + description: 'AI-powered spam detection', + category: 'moderation', + source: 'official', + featured: false, + downloads: 1200, + }), + makePlugin({ + name: 'community-badges', + displayName: 'Community Badges', + description: 'Custom badge system for community members', + category: 'social', + source: 'community', + featured: true, + downloads: 80, + }), + makePlugin({ + name: '@barazo/plugin-analytics', + displayName: 'Analytics Dashboard', + description: 'Privacy-friendly forum analytics', + category: 'admin', + source: 'official', + featured: false, + downloads: 300, + }), +] + +// --------------------------------------------------------------------------- +// searchRegistryPlugins +// --------------------------------------------------------------------------- + +describe('searchRegistryPlugins', () => { + it('returns all plugins when no filters are provided', () => { + const results = searchRegistryPlugins(samplePlugins, {}) + expect(results).toHaveLength(4) + }) + + it('filters by text query matching name', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'polls' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('@barazo/plugin-polls') + }) + + it('filters by text query matching displayName', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'Analytics Dashboard' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('@barazo/plugin-analytics') + }) + + it('filters by text query matching description', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'spam detection' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('@barazo/plugin-spam-filter') + }) + + it('text query is case-insensitive', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'POLLS' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('@barazo/plugin-polls') + }) + + it('filters by category', () => { + const results = searchRegistryPlugins(samplePlugins, { category: 'social' }) + expect(results).toHaveLength(2) + expect(results.map((p) => p.name)).toContain('@barazo/plugin-polls') + expect(results.map((p) => p.name)).toContain('community-badges') + }) + + it('filters by source', () => { + const results = searchRegistryPlugins(samplePlugins, { source: 'community' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('community-badges') + }) + + it('combines query and category filters', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'badge', category: 'social' }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('community-badges') + }) + + it('combines all three filters', () => { + const results = searchRegistryPlugins(samplePlugins, { + q: 'badge', + category: 'social', + source: 'community', + }) + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('community-badges') + }) + + it('returns empty array when no plugins match', () => { + const results = searchRegistryPlugins(samplePlugins, { q: 'nonexistent-plugin-xyz' }) + expect(results).toHaveLength(0) + }) + + it('returns empty array when category filter has no match', () => { + const results = searchRegistryPlugins(samplePlugins, { category: 'nonexistent' }) + expect(results).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// getFeaturedPlugins +// --------------------------------------------------------------------------- + +describe('getFeaturedPlugins', () => { + it('returns only featured plugins', () => { + const results = getFeaturedPlugins(samplePlugins) + expect(results).toHaveLength(2) + expect(results.every((p) => p.featured)).toBe(true) + }) + + it('returns empty array when no plugins are featured', () => { + const unfeatured = samplePlugins.map((p) => ({ ...p, featured: false })) + const results = getFeaturedPlugins(unfeatured) + expect(results).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// getRegistryIndex +// --------------------------------------------------------------------------- + +describe('getRegistryIndex', () => { + const registryData = { + version: 1, + updatedAt: '2026-03-06T00:00:00Z', + plugins: samplePlugins, + } + + function createMockApp(cacheValue: string | null = null): FastifyInstance { + return { + cache: { + get: vi.fn().mockResolvedValue(cacheValue), + set: vi.fn().mockResolvedValue('OK'), + }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }, + } as unknown as FastifyInstance + } + + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('returns plugins from cache when available', async () => { + const app = createMockApp(JSON.stringify(registryData)) + + const result = await getRegistryIndex(app) + + expect(result).toHaveLength(4) + expect(result[0]?.name).toBe('@barazo/plugin-polls') + // Should not have called fetch + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(app.cache.get).toHaveBeenCalledWith('plugin:registry:index') + }) + + it('fetches from registry when cache is empty', async () => { + const app = createMockApp(null) + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(registryData), + }) + + const result = await getRegistryIndex(app) + + expect(result).toHaveLength(4) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://registry.barazo.forum/index.json', + expect.objectContaining({ signal: expect.any(AbortSignal) as AbortSignal }) + ) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(app.cache.set).toHaveBeenCalledWith( + 'plugin:registry:index', + JSON.stringify(registryData), + 'EX', + 3600 + ) + }) + + it('returns empty array when fetch fails', async () => { + const app = createMockApp(null) + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }) + + const result = await getRegistryIndex(app) + + expect(result).toHaveLength(0) + expect(app.log.warn).toHaveBeenCalled() + }) + + it('returns empty array when fetch throws', async () => { + const app = createMockApp(null) + + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await getRegistryIndex(app) + + expect(result).toHaveLength(0) + expect(app.log.warn).toHaveBeenCalled() + }) + + it('fetches fresh when cache contains invalid JSON', async () => { + const app = createMockApp('not-valid-json') + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(registryData), + }) + + const result = await getRegistryIndex(app) + + expect(result).toHaveLength(4) + expect(globalThis.fetch).toHaveBeenCalled() + }) +}) diff --git a/tests/unit/routes/plugin-registry.test.ts b/tests/unit/routes/plugin-registry.test.ts new file mode 100644 index 0000000..da5a289 --- /dev/null +++ b/tests/unit/routes/plugin-registry.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for the public plugin registry routes. + * These routes do NOT require authentication. + */ + +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import type { RegistryPlugin } from '../../../src/lib/plugins/registry.js' +import { createMockDb, createChainableProxy } from '../../helpers/mock-db.js' + +// --------------------------------------------------------------------------- +// Mock the registry module so we control what getRegistryIndex returns +// --------------------------------------------------------------------------- + +const { mockGetRegistryIndex } = vi.hoisted(() => ({ + mockGetRegistryIndex: vi.fn(), +})) + +vi.mock('../../../src/lib/plugins/registry.js', () => ({ + getRegistryIndex: mockGetRegistryIndex, + searchRegistryPlugins: vi.fn( + (plugins: unknown[], params: { q?: string; category?: string; source?: string }) => { + // Re-implement minimal search for tests + let results = plugins as Array<{ + name: string + displayName: string + description: string + category: string + source: string + featured: boolean + }> + if (params.q) { + const q = params.q.toLowerCase() + results = results.filter( + (p) => + p.name.includes(q) || + p.displayName.toLowerCase().includes(q) || + p.description.toLowerCase().includes(q) + ) + } + if (params.category) results = results.filter((p) => p.category === params.category) + if (params.source) results = results.filter((p) => p.source === params.source) + return results + } + ), + getFeaturedPlugins: vi.fn((plugins: Array<{ featured: boolean }>) => + plugins.filter((p) => p.featured) + ), +})) + +// Import routes after mocks +import { adminPluginRoutes } from '../../../src/routes/admin-plugins.js' + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function makeRegistryPlugin(overrides: Partial = {}): RegistryPlugin { + return { + name: '@barazo/plugin-test', + displayName: 'Test Plugin', + description: 'A test plugin', + version: '1.0.0', + source: 'official', + category: 'moderation', + barazoVersion: '^0.1.0', + author: { name: 'Barazo Team' }, + license: 'MIT', + npmUrl: 'https://www.npmjs.com/package/@barazo/plugin-test', + approved: true, + featured: false, + downloads: 100, + ...overrides, + } +} + +const registryPlugins: RegistryPlugin[] = [ + makeRegistryPlugin({ + name: '@barazo/plugin-polls', + displayName: 'Polls', + description: 'Add polls to forum topics', + category: 'social', + featured: true, + downloads: 500, + }), + makeRegistryPlugin({ + name: '@barazo/plugin-spam', + displayName: 'Spam Filter', + description: 'AI-powered spam detection', + category: 'moderation', + featured: false, + downloads: 1200, + }), + makeRegistryPlugin({ + name: 'community-badges', + displayName: 'Community Badges', + description: 'Custom badge system', + category: 'social', + source: 'community', + featured: true, + downloads: 80, + }), +] + +// --------------------------------------------------------------------------- +// Mock env + DB +// --------------------------------------------------------------------------- + +const mockEnv = { + COMMUNITY_DID: 'did:plc:community123', + UPLOAD_MAX_SIZE_BYTES: 5_242_880, + RATE_LIMIT_WRITE: 10, + RATE_LIMIT_READ_ANON: 100, + RATE_LIMIT_READ_AUTH: 300, +} as Env + +const mockDb = createMockDb() + +function resetAllDbMocks(): void { + const selectChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(createChainableProxy()) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(createChainableProxy([])) + mockDb.delete.mockReturnValue(createChainableProxy()) +} + +// --------------------------------------------------------------------------- +// requireAdmin mock (for the admin routes we don't test here) +// --------------------------------------------------------------------------- + +function createMockRequireAdmin() { + return ( + _request: { user?: RequestUser }, + reply: { status: (code: number) => { send: (body: unknown) => void } }, + _done: () => void + ) => { + reply.status(401).send({ error: 'Authentication required' }) + } +} + +// --------------------------------------------------------------------------- +// Build test app +// --------------------------------------------------------------------------- + +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', {} as never) + app.decorate('requireAdmin', createMockRequireAdmin()) + app.decorate('storage', {} as never) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = mockEnv.COMMUNITY_DID + done() + }) + + await app.register(adminPluginRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('plugin registry routes', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp() + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + mockGetRegistryIndex.mockResolvedValue(registryPlugins) + }) + + // ========================================================================= + // GET /api/plugins/registry/search + // ========================================================================= + + describe('GET /api/plugins/registry/search', () => { + it('returns all plugins when no query params are provided', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(3) + }) + + it('does not require authentication', async () => { + // No Authorization header -- should still succeed + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search', + }) + + expect(response.statusCode).toBe(200) + }) + + it('filters by text query', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search?q=polls', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(1) + expect(body.plugins[0]?.name).toBe('@barazo/plugin-polls') + }) + + it('filters by category', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search?category=social', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(2) + }) + + it('filters by source', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search?source=community', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(1) + expect(body.plugins[0]?.name).toBe('community-badges') + }) + + it('combines multiple filters', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search?q=badge&category=social&source=community', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(1) + expect(body.plugins[0]?.name).toBe('community-badges') + }) + + it('returns empty array when no matches', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search?q=nonexistent', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(0) + }) + + it('returns empty array when registry fetch fails', async () => { + mockGetRegistryIndex.mockResolvedValue([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/search', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(0) + }) + }) + + // ========================================================================= + // GET /api/plugins/registry/featured + // ========================================================================= + + describe('GET /api/plugins/registry/featured', () => { + it('returns only featured plugins', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/featured', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(2) + expect(body.plugins.every((p) => p.featured)).toBe(true) + }) + + it('does not require authentication', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/featured', + }) + + expect(response.statusCode).toBe(200) + }) + + it('returns empty array when no featured plugins exist', async () => { + mockGetRegistryIndex.mockResolvedValue( + registryPlugins.map((p) => ({ ...p, featured: false })) + ) + + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/featured', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(0) + }) + + it('returns empty array when registry fetch fails', async () => { + mockGetRegistryIndex.mockResolvedValue([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/plugins/registry/featured', + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ plugins: RegistryPlugin[] }>() + expect(body.plugins).toHaveLength(0) + }) + }) +}) -- 2.51.2