Admin can now upload a png/jpg event photo (SPEC §8 exception added): - new image-upload admin field kind with live preview, uploading via POST /api/admin/upload (fenja-only, type + 5MB validation); - files stored under data/uploads (gitignored, BIFROST_UPLOAD_DIR overridable) and served by GET /uploads/[file] with a traversal guard. Reworks the /pulse event card: the greeting moved inside a taller box, the "next gathering" label sits above the date + title, and the photo renders as a top-right background that blends into the indigo via gradient masks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 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',
|
|
'image-upload',
|
|
]);
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
});
|