mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
1727732841
A new manifest capability poiCategories (up to 4, icon from an allow-list, strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the hook:poi-category-provider permission. GET /api/plugin-pois in the plugin contributions module answers them in the /api/maps/pois shape, with search_plugin_pois as the MCP twin. plugin: ids no longer count as Google place ids. SDK 1.8.0 carries the types, validation and docs. On the map, one hook merges the built-in categories with the ones active plugins declare, and the pill shows the plugin chips after a divider on desktop and phone, scrolling sideways instead of shrinking a chip below its touch target. A plugin chip searches through a repo that refuses offline and parses the answer strictly. Its pins, on Leaflet and GL, use the category's own icon and color, both checked again before they reach any markup, and the hover card lists the plugin's details as escaped text. A plugin POI can be added as a place like any other. The admin panels on desktop and phone show a chip for the new permission and the categories a plugin would add, from one shared module. Addresses discussion #1781.
124 lines
5.7 KiB
TypeScript
124 lines
5.7 KiB
TypeScript
/**
|
|
* PluginsFeedController (#plugins): the authenticated active-plugin feed. Focused
|
|
* on the parts that re-validate hand-editable DB JSON: capability-derived fields
|
|
* must never surface values the manifest parser would have rejected, and
|
|
* routeProfiles must only appear for plugins whose hook:route-provider grant is
|
|
* actually recorded — a declared-but-ungranted provider can never serve a route,
|
|
* so offering its profiles in the picker would produce dead buttons.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { rows, pluginsEnabled } = vi.hoisted(() => ({
|
|
rows: { value: [] as Array<Record<string, unknown>> },
|
|
pluginsEnabled: vi.fn(() => true),
|
|
}));
|
|
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } }));
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
|
|
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
|
|
|
const row = (over: Record<string, unknown> = {}) => ({
|
|
id: 'p1', name: 'P', type: 'integration', icon: null,
|
|
capabilities: '{}', granted_permissions: '[]',
|
|
...over,
|
|
});
|
|
|
|
describe('PluginsFeedController', () => {
|
|
const c = new PluginsFeedController(new DatabaseService(dbConn));
|
|
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
|
|
|
|
it('returns an empty feed when the runtime is disabled', () => {
|
|
pluginsEnabled.mockReturnValue(false);
|
|
rows.value = [row()];
|
|
expect(c.list()).toEqual({ plugins: [] });
|
|
});
|
|
|
|
it('serves routeProfiles only alongside the recorded hook:route-provider grant', () => {
|
|
rows.value = [
|
|
row({ id: 'granted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }), granted_permissions: JSON.stringify(['hook:route-provider']) }),
|
|
row({ id: 'ungranted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }) }),
|
|
];
|
|
const { plugins } = c.list();
|
|
expect(plugins.find(p => p.id === 'granted')?.routeProfiles).toEqual([{ id: 'ev', label: 'EV' }]);
|
|
expect(plugins.find(p => p.id === 'ungranted')?.routeProfiles).toBeUndefined();
|
|
});
|
|
|
|
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', () => {
|
|
rows.value = [row({
|
|
id: 'edited',
|
|
capabilities: JSON.stringify({
|
|
routeProfiles: [
|
|
{ id: '../up', label: 'bad id' },
|
|
{ id: 'ok', label: ' L '.padEnd(60, 'x') },
|
|
{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' },
|
|
],
|
|
}),
|
|
granted_permissions: JSON.stringify(['hook:route-provider']),
|
|
})];
|
|
const profiles = c.list().plugins[0].routeProfiles!;
|
|
expect(profiles.every(p => /^[a-z][a-z0-9-]{0,23}$/.test(p.id))).toBe(true);
|
|
expect(profiles.length).toBeLessThanOrEqual(3);
|
|
expect(profiles.every(p => p.label.length <= 40)).toBe(true);
|
|
});
|
|
|
|
describe('poiCategories (#1781)', () => {
|
|
const cat = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' };
|
|
const granted = JSON.stringify(['hook:poi-category-provider']);
|
|
|
|
it('serves them only alongside the recorded hook:poi-category-provider grant', () => {
|
|
rows.value = [
|
|
row({ id: 'granted', capabilities: JSON.stringify({ poiCategories: [cat] }), granted_permissions: granted }),
|
|
row({ id: 'ungranted', capabilities: JSON.stringify({ poiCategories: [cat] }) }),
|
|
row({ id: 'searchOnly', capabilities: JSON.stringify({ poiCategories: [cat] }), granted_permissions: JSON.stringify(['hook:search-provider']) }),
|
|
];
|
|
const { plugins } = c.list();
|
|
expect(plugins.find(p => p.id === 'granted')?.poiCategories).toEqual([cat]);
|
|
expect(plugins.find(p => p.id === 'ungranted')?.poiCategories).toBeUndefined();
|
|
expect(plugins.find(p => p.id === 'searchOnly')?.poiCategories).toBeUndefined();
|
|
});
|
|
|
|
it('re-validates a hand-edited row: bad colour, icon or id dropped, duplicates and the fifth one gone', () => {
|
|
rows.value = [row({
|
|
capabilities: JSON.stringify({
|
|
poiCategories: [
|
|
{ ...cat, color: '#fff;background:url(https://tracker.example)' },
|
|
{ ...cat, id: 'x', icon: 'Skull' },
|
|
{ ...cat, id: '../x' },
|
|
{ ...cat, id: 'ok', label: 'Water \u{1F6B0}' },
|
|
{ ...cat, id: 'ok' },
|
|
{ ...cat, id: 'late' },
|
|
],
|
|
}),
|
|
granted_permissions: granted,
|
|
})];
|
|
// The first four entries are the ones read (the cap), and only one of them survives.
|
|
expect(c.list().plugins[0].poiCategories).toEqual([{ ...cat, id: 'ok', label: 'Water' }]);
|
|
});
|
|
|
|
it('omits the field when nothing survives', () => {
|
|
rows.value = [row({ capabilities: JSON.stringify({ poiCategories: [{ ...cat, color: 'red' }] }), granted_permissions: granted })];
|
|
expect(c.list().plugins[0]).not.toHaveProperty('poiCategories');
|
|
});
|
|
});
|
|
|
|
it('flags geolocation only when the grant is recorded', () => {
|
|
rows.value = [
|
|
row({ id: 'granted', granted_permissions: JSON.stringify(['geolocation:read']) }),
|
|
row({ id: 'ungranted' }),
|
|
];
|
|
const { plugins } = c.list();
|
|
expect(plugins.find(p => p.id === 'granted')?.geolocation).toBe(true);
|
|
expect(plugins.find(p => p.id === 'ungranted')?.geolocation).toBeUndefined();
|
|
});
|
|
|
|
it('survives malformed JSON blobs without dropping the plugin', () => {
|
|
rows.value = [row({ capabilities: '{not json', granted_permissions: 'also not' })];
|
|
const { plugins } = c.list();
|
|
expect(plugins).toHaveLength(1);
|
|
expect(plugins[0].routeProfiles).toBeUndefined();
|
|
expect(plugins[0].slot).toBe('sidebar');
|
|
});
|
|
});
|