149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
parsePluginCatalog,
|
|
PluginPolicyClient,
|
|
} from '../../electron/services/plugin-policy-client';
|
|
|
|
const catalog = {
|
|
schema_version: 1,
|
|
catalog_version: 'catalog-1',
|
|
pricing_version: null,
|
|
plugins: [{
|
|
plugin_id: 'makelore.data-service',
|
|
supported_contract_versions: [1],
|
|
status: 'active',
|
|
capabilities: [
|
|
{
|
|
capability_id: 'data-service.documents',
|
|
operations: [
|
|
{
|
|
operation: 'get_document',
|
|
billing: {
|
|
mode: 'included',
|
|
entitlement_scope: null,
|
|
notice: 'Included in development data service',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}],
|
|
};
|
|
|
|
describe('PluginPolicyClient', () => {
|
|
it('parses the exact catalog and rejects unknown or malformed fields', () => {
|
|
expect(parsePluginCatalog(catalog)).toMatchObject({
|
|
schema_version: 1,
|
|
catalog_version: 'catalog-1',
|
|
plugins: [{ plugin_id: 'makelore.data-service' }],
|
|
});
|
|
expect(() => parsePluginCatalog({ ...catalog, extra: true })).toThrow('unexpected fields');
|
|
expect(() => parsePluginCatalog({
|
|
...catalog,
|
|
plugins: [{
|
|
...catalog.plugins[0],
|
|
capabilities: [{
|
|
...catalog.plugins[0].capabilities[0],
|
|
operations: [{
|
|
...catalog.plugins[0].capabilities[0].operations[0],
|
|
billing: { mode: 'included', entitlement_scope: 'wrong', notice: 'nope' },
|
|
}],
|
|
}],
|
|
}],
|
|
})).toThrow();
|
|
});
|
|
|
|
it('accepts an unavailable platform-metered policy without exposing a price', () => {
|
|
const parsed = parsePluginCatalog({
|
|
...catalog,
|
|
plugins: [{
|
|
...catalog.plugins[0],
|
|
capabilities: [{
|
|
...catalog.plugins[0].capabilities[0],
|
|
operations: [{
|
|
...catalog.plugins[0].capabilities[0].operations[0],
|
|
billing: {
|
|
mode: 'platform_metered',
|
|
status: 'billing_unavailable',
|
|
entitlement_scope: 'data-service.documents',
|
|
notice: 'Pricing is unavailable',
|
|
},
|
|
}],
|
|
}],
|
|
}],
|
|
});
|
|
expect(parsed.plugins[0]?.capabilities[0]?.operations[0]?.billing).toEqual({
|
|
mode: 'platform_metered',
|
|
status: 'billing_unavailable',
|
|
entitlement_scope: 'data-service.documents',
|
|
notice: 'Pricing is unavailable',
|
|
});
|
|
});
|
|
|
|
it('coalesces concurrent refreshes and marks the verified catalog stale after failure', async () => {
|
|
let resolveRequest: ((response: Response) => void) | undefined;
|
|
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => {
|
|
resolveRequest = resolve;
|
|
}));
|
|
const client = new PluginPolicyClient({
|
|
fetchImpl,
|
|
apiBaseUrl: 'https://works.example',
|
|
now: () => 123,
|
|
});
|
|
const first = client.refresh();
|
|
const second = client.refresh();
|
|
expect(first).toBe(second);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
resolveRequest?.(new Response(JSON.stringify(catalog), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}));
|
|
await expect(first).resolves.toMatchObject({ status: 'current', revision: 1 });
|
|
|
|
fetchImpl.mockRejectedValueOnce(new Error('offline'));
|
|
await expect(client.refresh()).resolves.toMatchObject({
|
|
status: 'stale',
|
|
revision: 1,
|
|
errorCode: 'plugin_backend_unavailable',
|
|
});
|
|
expect(client.getState().catalog).toEqual(expect.objectContaining({ catalog_version: 'catalog-1' }));
|
|
});
|
|
|
|
it('keeps an unavailable first state when no catalog was verified', async () => {
|
|
const client = new PluginPolicyClient({ fetchImpl: vi.fn().mockRejectedValue(new Error('offline')) });
|
|
await expect(client.refresh()).resolves.toMatchObject({
|
|
status: 'unavailable',
|
|
catalog: null,
|
|
revision: 0,
|
|
errorCode: 'plugin_backend_unavailable',
|
|
});
|
|
});
|
|
|
|
it.each(['headers', 'body'] as const)(
|
|
'bounds a catalog request whose %s never settle',
|
|
async (phase) => {
|
|
const fetchImpl = vi.fn((_input: string | URL, init?: RequestInit) => {
|
|
if (phase === 'headers') {
|
|
return new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
|
|
});
|
|
}
|
|
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
init?.signal?.addEventListener('abort', () => controller.error(init.signal?.reason), { once: true });
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
});
|
|
const client = new PluginPolicyClient({ fetchImpl, requestTimeoutMs: 20 });
|
|
|
|
await expect(client.refresh()).resolves.toMatchObject({
|
|
status: 'unavailable', catalog: null, revision: 0,
|
|
errorCode: 'plugin_backend_unavailable',
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
});
|