`/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>
103 lines
3.9 KiB
TypeScript
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([]);
|
|
});
|
|
});
|