Files
platform/src/servers/plugins/marketplace.test.ts
T
pastilhasandClaude Opus 5 1b65b83f1f the plugins screen becomes /marketplace
`/plugins` redirects rather than 404s: the word is in the dock, in the docs and in
whatever anyone bookmarked, and a route that worked yesterday returning nothing
reads as the feature having been removed. The API keeps its name — only the screen
moved — so the permission entry lists both paths, because a client-side redirect
is still a route that has to resolve before it can send anyone anywhere.

Also: the fetch stubs in marketplace.test.ts needed `as unknown as typeof fetch`.
Bun's `typeof fetch` carries `preconnect`, so a bare stub is not assignable —
tests passed while tsgo did not, which is the wrong way round to find out.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:10:23 +00:00

103 lines
3.9 KiB
TypeScript

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('<html>502 Bad Gateway</html>', { 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([]);
});
});