Files
immich-folio/lib/__tests__/settings-schema.test.ts

125 lines
4.7 KiB
TypeScript
Raw Normal View History

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']);
});
});