import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { clearCatalogueCache, fetchCatalogue } from './marketplace'; // The three answers — live, cached, bundled — and the rule that none of them is an exception. // // The plugins page is blocked on this call, and an owner's INSTALLED plugins are managed on that same // page. So a marketplace that is down must degrade to something true rather than throw: the failure // mode being defended against is "officer.dev has a bad afternoon and nobody can uninstall anything". const realFetch = globalThis.fetch; const entry = (appName: string) => ({ appName, publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0', label: appName, summary: 'a plugin', color: '#fff', source: `https://gitea.officer.dev/plugins/${appName}.git`, permissions: [], has: { api: true, schema: false, sidecar: false, web: true }, }); const respondWith = (body: unknown, status = 200) => { globalThis.fetch = (async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, })) as unknown as typeof fetch; }; const failWith = (message: string) => { globalThis.fetch = (async () => { throw new Error(message); }) as unknown as typeof fetch; }; beforeEach(() => clearCatalogueCache()); afterEach(() => { globalThis.fetch = realFetch; clearCatalogueCache(); }); describe('fetchCatalogue', () => { test('serves what the marketplace returns', async () => { respondWith([entry('music'), entry('offscale')]); expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'offscale']); }); test('falls back to the bundled catalogue when the store is unreachable', async () => { failWith('getaddrinfo ENOTFOUND'); const entries = await fetchCatalogue(); // Not asserting the exact list — that changes as plugins are published. The property is that a // machine with no network still has a usable store. expect(entries.length).toBeGreaterThan(0); expect(entries.every((e) => e.source.startsWith('https://'))).toBe(true); }); test('a cached answer outlives the store going down', async () => { respondWith([entry('music')]); await fetchCatalogue(); // The cache is deliberately NOT cleared here — surviving the store going down is the whole point. failWith('connection refused'); const entries = await fetchCatalogue(); expect(entries.map((e) => e.appName)).toEqual(['music']); }); test('an HTTP error is a failure, not a catalogue', async () => { respondWith({ error: 'catalogue unavailable' }, 503); const entries = await fetchCatalogue(); expect(entries.some((e) => e.appName === 'music')).toBe(true); // the bundled list, not the error body }); test('a proxy serving HTML instead of JSON does not empty the store', async () => { globalThis.fetch = (async () => new Response('502 Bad Gateway', { status: 200 })) as unknown as typeof fetch; const entries = await fetchCatalogue(); expect(entries.length).toBeGreaterThan(0); }); test('malformed entries are dropped individually, not fatally', async () => { respondWith([entry('music'), { appName: 'broken' }, entry('example')]); expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'example']); }); test('a non-https source is refused — it drives git clone', async () => { respondWith([{ ...entry('evil'), source: 'file:///etc/passwd' }, entry('music')]); expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music']); }); test('an empty catalogue is a valid answer and is cached as one', async () => { respondWith([]); expect(await fetchCatalogue()).toEqual([]); // Cached: a store that legitimately lists nothing must not fall through to the bundled list, or an // owner who unpublished everything would keep seeing plugins that are gone. failWith('down'); expect(await fetchCatalogue()).toEqual([]); }); });