Files
dashboardAPI/test/basic/slice48-folder-resolve-by-name.basic.test.js
znetsixe 5d651b59ef feat(dashboardAPI): resolve Grafana folder by name (fixes stale folderUid 400s)
A pinned folderUid goes stale whenever Grafana is rebuilt — the same-named
folder returns with a fresh uid and every dashboard upsert then 400s
"folder not found", silently dropping all generated dashboards.

Add a folderTitle config field: when set, resolveFolderUid() looks the folder
up by name (GET /api/folders), creates it if absent (POST /api/folders),
caches the uid for the process, and falls back to the configured folderUid on
any failure (never worse than the pinned behavior). The emit handlers
(registerChild/regenerateDashboard/emitDashboardsFor) are now async and await
the resolution. folderUid retained as an explicit override/fallback.

Locked by slice48-folder-resolve-by-name; existing emit tests made async.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:02:38 +02:00

101 lines
4.2 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../../src/specificClass.js');
const { registerChild } = require('../../src/commands/handlers.js');
// Minimal fetch double. `routes` maps `${method} ${pathname}` to a response
// descriptor { ok, status, body }. Records every call for assertions.
function makeFetch(routes) {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
const method = opts.method || 'GET';
const { pathname } = new URL(url);
calls.push({ method, pathname, body: opts.body });
const r = routes[`${method} ${pathname}`];
if (!r) return { ok: false, status: 404, json: async () => ({ message: 'not found' }) };
if (typeof r === 'function') return r();
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
};
fetchImpl.calls = calls;
return fetchImpl;
}
function api(grafanaConnector) {
return new DashboardApi({ grafanaConnector });
}
test('no folderTitle → returns configured folderUid without any fetch (legacy path)', async () => {
const a = api({ folderUid: 'pinned-uid' });
const fetchImpl = makeFetch({});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'pinned-uid');
assert.equal(fetchImpl.calls.length, 0, 'must not call Grafana when no folderTitle is set');
});
test('folderTitle matches an existing folder (case-insensitive) → returns its uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }, { title: 'evolv', uid: 'bfncls6af0b9cb' }] },
});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'bfncls6af0b9cb');
assert.equal(fetchImpl.calls.filter((c) => c.method === 'POST').length, 0, 'must not create when found');
});
test('resolution is cached → second call makes no further fetch', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({ 'GET /api/folders': { body: [{ title: 'EVOLV', uid: 'u1' }] } });
await a.resolveFolderUid({ fetchImpl });
await a.resolveFolderUid({ fetchImpl });
assert.equal(fetchImpl.calls.length, 1, 'second resolve should hit the cache');
});
test('folder absent → creates it by name and returns the new uid', async () => {
const a = api({ folderTitle: 'EVOLV' });
const fetchImpl = makeFetch({
'GET /api/folders': { body: [{ title: 'Other', uid: 'x' }] },
'POST /api/folders': { status: 200, body: { uid: 'created-uid', title: 'EVOLV' } },
});
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'created-uid');
const post = fetchImpl.calls.find((c) => c.method === 'POST');
assert.equal(JSON.parse(post.body).title, 'EVOLV');
});
test('fetch throws → falls back to configured folderUid (never worse than pinned)', async () => {
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
const uid = await a.resolveFolderUid({ fetchImpl });
assert.equal(uid, 'fallback-uid');
});
test('no fetch implementation available → falls back to configured folderUid', async () => {
const a = api({ folderTitle: 'EVOLV', folderUid: 'fallback-uid' });
// Pass an explicit non-function (not undefined, which would trigger the
// globalThis.fetch default) to exercise the "no fetch available" branch.
const uid = await a.resolveFolderUid({ fetchImpl: null });
assert.equal(uid, 'fallback-uid');
});
test('emit path stamps the resolved folderUid onto every upsert payload', async () => {
const a = api({ folderTitle: 'EVOLV' });
// Force a deterministic resolution without standing up fetch.
a.resolveFolderUid = async () => 'resolved-folder-uid';
const childSource = {
config: { general: { id: 'm1', name: 'Level' }, functionality: { softwareType: 'measurement' } },
};
const sent = [];
const ctx = { node: { id: 'dapi' }, send: (m) => sent.push(m) };
await registerChild(a, { payload: childSource }, ctx);
assert.ok(sent.length >= 1, 'should emit at least one create');
for (const m of sent) {
assert.equal(m.topic, 'create');
assert.equal(m.payload.folderUid, 'resolved-folder-uid');
}
});