125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
|
|
import { describe, it, expect } from 'vitest';
|
|||
|
|
import fs from 'fs';
|
|||
|
|
import path from 'path';
|
|||
|
|
import yaml from 'js-yaml';
|
|||
|
|
import { validateSettings } from '../config/settingsSchema';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Half of these tests are about what must *not* be rejected.
|
|||
|
|
*
|
|||
|
|
* The failure mode of a validator on a config file is not "it let something
|
|||
|
|
* through" — it is "it locked an operator out of their own settings, through
|
|||
|
|
* the panel that writes the file". There is no schemaVersion and no migration
|
|||
|
|
* code, so older and newer configurations are both in the wild.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const ok = (input: unknown) => validateSettings(input).ok;
|
|||
|
|
const errors = (input: unknown) => {
|
|||
|
|
const result = validateSettings(input);
|
|||
|
|
return result.ok ? [] : result.errors;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
describe('validateSettings — accepts what works today', () => {
|
|||
|
|
it('accepts the example settings file this repo ships with', () => {
|
|||
|
|
// content/settings.yaml is gitignored — the example is the one every
|
|||
|
|
// installation starts from, and the one CI actually has.
|
|||
|
|
const raw = fs.readFileSync(path.join(process.cwd(), 'content/settings.yaml.example'), 'utf8');
|
|||
|
|
|
|||
|
|
expect(validateSettings(yaml.load(raw))).toEqual({ ok: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts an empty settings file', () => {
|
|||
|
|
// Everything has a default; a site with no settings.yaml is a valid site.
|
|||
|
|
expect(ok({})).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts both spellings of theme', () => {
|
|||
|
|
expect(ok({ theme: 'minimal' })).toBe(true);
|
|||
|
|
expect(ok({ theme: { preset: 'minimal', accent: '#c0ffee', radius: 4 } })).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts a watermark opacity written as a percentage', () => {
|
|||
|
|
// The #508 workaround: values above 1 are read as percentages. A validator
|
|||
|
|
// that insisted on 0–1 would reject configurations that render correctly.
|
|||
|
|
expect(ok({ watermark: { opacity: 90 } })).toBe(true);
|
|||
|
|
expect(ok({ watermark: { opacity: 0.5 } })).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts a colour mode it does not recognise', () => {
|
|||
|
|
// resolveColorMode falls back to 'dark'. Rejecting here would be stricter
|
|||
|
|
// than the behaviour it guards.
|
|||
|
|
expect(ok({ mode: 'sepia' })).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts keys this version has never heard of', () => {
|
|||
|
|
expect(ok({ title: 'Folio', somethingFromANewerVersion: { nested: true } })).toBe(true);
|
|||
|
|
expect(ok({ theme: { preset: 'noir', futureKnob: 3 } })).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('accepts the legacy exifOnHover switch alongside the group form', () => {
|
|||
|
|
expect(ok({ exifOnHover: false, exif: { camera: true, caption: false } })).toBe(true);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('validateSettings — rejects what breaks the site', () => {
|
|||
|
|
it('rejects a payload that is not an object', () => {
|
|||
|
|
expect(ok('title: Folio')).toBe(false);
|
|||
|
|
expect(ok([{ title: 'Folio' }])).toBe(false);
|
|||
|
|
expect(ok(null)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rejects a scalar where a section belongs', () => {
|
|||
|
|
// `grid: 3` reads as "three columns" to a human and as a crash to
|
|||
|
|
// buildCoverGridVars.
|
|||
|
|
const found = errors({ grid: 3 });
|
|||
|
|
|
|||
|
|
expect(found).toHaveLength(1);
|
|||
|
|
expect(found[0].field).toBe('grid');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rejects a section where a scalar belongs', () => {
|
|||
|
|
const found = errors({ title: { de: 'Folio', en: 'Folio' } });
|
|||
|
|
|
|||
|
|
expect(found[0].field).toBe('title');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('names the exact field, however deep', () => {
|
|||
|
|
const found = errors({ theme: { fonts: { heading: 42 } } });
|
|||
|
|
|
|||
|
|
expect(found.map((e) => e.field)).toContain('theme.fonts.heading');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rejects a theme that is neither a preset name nor a section', () => {
|
|||
|
|
// The separate theme check must not turn into a hole: anything that is not
|
|||
|
|
// a string still has to look like the object form.
|
|||
|
|
expect(ok({ theme: 3 })).toBe(false);
|
|||
|
|
expect(ok({ theme: ['minimal'] })).toBe(false);
|
|||
|
|
expect(errors({ theme: 3 })[0].field).toBe('theme');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('names an index inside navLinks', () => {
|
|||
|
|
const found = errors({ navLinks: [{ label: 'Shop', url: 'https://example.com' }, 'nope'] });
|
|||
|
|
|
|||
|
|
expect(found[0].field).toBe('navLinks.1');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rejects a radius written with its unit', () => {
|
|||
|
|
// #633: `radius: 8px` reaches CSS as `8pxpx`. The value itself belongs to
|
|||
|
|
// the resolver, but a string where a number belongs is a type error, and
|
|||
|
|
// that half is caught here for anything saved from the panel.
|
|||
|
|
const found = errors({ theme: { radius: '8px' } });
|
|||
|
|
|
|||
|
|
expect(found.map((e) => e.field)).toContain('theme.radius');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('rejects a list where a record belongs', () => {
|
|||
|
|
expect(ok({ legal: ['Ralf', 'Berlin'] })).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('reports every offending field at once, not just the first', () => {
|
|||
|
|
const found = errors({ title: 1, subtitle: 2, map: 'yes' });
|
|||
|
|
|
|||
|
|
expect(found.map((e) => e.field).sort()).toEqual(['map', 'subtitle', 'title']);
|
|||
|
|
});
|
|||
|
|
});
|