project-bifrost-platform/tests/admin-resources.test.ts
Jonathan Hvid 8bbf8568f4 feat(admin): retire old admin, add resource verifier, redirect /admin
The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.

- tests/admin-resources.test.ts: vitest-based verifier that walks
  every registered resource and asserts:
    - identity fields (key/label/plural/singular/groupKey)
    - list.queryFn, action.handler, ops.* members are functions
    - column kinds are in the registered set (text/pill/relative-date/
      number/tag-list); same for columnsByFilter overrides
    - field kinds are in the registered set (11 kinds)
    - embed.component is in the registered set (pulse-sub-form)
    - resource keys are unique, action keys are unique per resource
    - at most one filter is isDefault
    - groupKey resolves to a real group
    - review-mode resources have at least one action
    - ops.create requires a non-null form
  87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
  ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
  UserEditTab — all replaced by the resource configs).

Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:48 +02:00

168 lines
6.2 KiB
TypeScript

/* ---------------------------------------------------------------------------
* Verifier for the Backstage admin resource registry.
*
* Walks every registered resource and asserts the invariants that keep the
* shared components renderable. Compile-time TypeScript already catches most
* shape issues via the strict Resource<T> generic — this suite covers what
* TS can't see at the value level (function-ness of handlers, kind strings
* actually being in the registered set, sentinel resource keys not colliding).
*
* Note on "every column.key is a valid field on the entity":
* That's a structural assertion best enforced at compile time. Resource<T>
* narrows the render/value callbacks to the entity's keys; this suite skips
* trying to re-check it at runtime.
* ------------------------------------------------------------------------- */
import { describe, it, expect } from 'vitest';
import { groups } from '../src/admin/resources';
import type {
Field,
Resource,
ResourceGroup,
Column,
FormEmbed,
} from '../src/admin/resource-types';
const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
'text',
'textarea',
'markdown',
'select',
'select-async',
'multi-select-async',
'multi-text',
'date',
'datetime',
'number',
'readonly',
]);
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
'text', 'pill', 'relative-date', 'number', 'tag-list',
]);
const KNOWN_EMBED_COMPONENTS: ReadonlySet<FormEmbed['component']> = new Set([
'pulse-sub-form',
]);
function allResources(): Resource[] {
return groups.flatMap((g: ResourceGroup) => g.resources as Resource[]);
}
describe('admin resource registry', () => {
it('has at least one group with resources registered', () => {
expect(groups.length).toBeGreaterThan(0);
expect(allResources().length).toBeGreaterThan(0);
});
it('every resource key is unique across the registry', () => {
const keys = allResources().map((r) => r.key);
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
expect(dups).toEqual([]);
});
it('every resource.groupKey points at a real group', () => {
const groupKeys = new Set(groups.map((g) => g.key));
for (const r of allResources()) {
expect(groupKeys.has(r.groupKey), `${r.key} → unknown groupKey ${r.groupKey}`).toBe(true);
}
});
describe.each(allResources())('resource: $key', (resource: Resource) => {
it('has required identity fields', () => {
expect(resource.key).toBeTruthy();
expect(resource.label).toBeTruthy();
expect(resource.pluralLabel).toBeTruthy();
expect(resource.singularLabel).toBeTruthy();
expect(resource.groupKey).toBeTruthy();
});
it('list.queryFn is a function', () => {
expect(typeof resource.list.queryFn).toBe('function');
});
it('every column has a registered kind (or none = text)', () => {
for (const col of resource.list.columns as Column<unknown>[]) {
const kind = col.kind ?? 'text';
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: column ${col.key} → unknown kind ${kind}`).toBe(true);
}
if (resource.list.columnsByFilter) {
for (const [filterKey, cols] of Object.entries(resource.list.columnsByFilter)) {
for (const col of cols as Column<unknown>[]) {
const kind = col.kind ?? 'text';
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: columnsByFilter.${filterKey}.${col.key} → unknown kind ${kind}`).toBe(true);
}
}
}
});
it('exactly one or zero filters is isDefault', () => {
const filters = resource.list.filters ?? [];
const defaults = filters.filter((f) => f.isDefault);
expect(defaults.length).toBeLessThanOrEqual(1);
});
it('every filter.predicate is a function', () => {
for (const f of resource.list.filters ?? []) {
expect(typeof f.predicate, `${resource.key}: filter ${f.key} predicate`).toBe('function');
}
});
it('every form field has a registered kind', () => {
for (const field of resource.form?.fields ?? []) {
expect(
KNOWN_FIELD_KINDS.has(field.kind),
`${resource.key}: field ${field.key} → unknown kind ${field.kind}`,
).toBe(true);
}
});
it('every embed.component is in the registered set', () => {
for (const embed of resource.form?.embeds ?? []) {
expect(
KNOWN_EMBED_COMPONENTS.has(embed.component),
`${resource.key}: embed ${embed.key} → unknown component ${embed.component}`,
).toBe(true);
}
});
it('every ops member is a function (when defined)', () => {
const ops = resource.ops;
if (ops.create) expect(typeof ops.create).toBe('function');
if (ops.update) expect(typeof ops.update).toBe('function');
if (ops.delete) expect(typeof ops.delete).toBe('function');
if (ops.getById) expect(typeof ops.getById).toBe('function');
});
it('every action.handler is a function', () => {
for (const action of resource.actions ?? []) {
expect(typeof action.handler, `${resource.key}: action ${action.key}`).toBe('function');
}
});
it('action keys are unique', () => {
const keys = (resource.actions ?? []).map((a) => a.key);
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
expect(dups).toEqual([]);
});
it('renders SOMETHING when an item is clicked (form OR summary, or no clicks)', () => {
// If form is null and there's no summary, the resource is non-clickable.
// If form is null but a summary is defined → review panel renders.
// If form is defined → edit panel renders.
// The only invalid shape is form=null + summary defined + no actions,
// which would render an empty review panel. Flag it.
if (resource.form === null && resource.summary !== undefined) {
const actions = resource.actions ?? [];
expect(actions.length, `${resource.key}: review-mode resource with no actions`).toBeGreaterThan(0);
}
});
it('when ops.create is defined, the form is defined too', () => {
// Can't render the create panel without a form.
if (resource.ops.create) {
expect(resource.form, `${resource.key}: ops.create is defined but form is null`).not.toBeNull();
}
});
});
});