test(website): broad automated coverage across the builder surface

83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.

Coverage:

- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
  + isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
  UI + plan say min 2). Fixed the regex in both the webapp and the
  mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
  self-parent cycle, 3-level nesting, all-orphans, empty-input cases
  on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
  of the 4 bundled templates — clone produces fresh UUIDs, page +
  block counts match, navConfig populated. Plus unknown-template and
  duplicate-slug rejection. Container-nesting is punted to the smoke
  test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
  (11) + sanity-checks (defaults satisfy own schema, enum + length
  bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
  parity, resolveTheme overrides, themeCssVars output format +
  heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
  the client tests for the server SSOT, plus new hostname validation
  cases (.mana.how reservation, length, malformed edges).

Refactor:

- apps/api/src/modules/website/reserved-slugs.ts now owns
  isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
  Pure functions live next to the other pure validators; easier to
  test + share.

All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 21:07:40 +02:00
parent 66bfcb3996
commit f20ace0358
10 changed files with 844 additions and 39 deletions

View file

@ -0,0 +1,239 @@
/**
* Block-schema tests. Pure Zod no Svelte runtime needed, runs under
* plain vitest in this package.
*
* Every block gets:
* - sanity: defaults pass the schema
* - a known-valid input
* - a known-invalid input (catches regex / enum / min / max drifts)
*/
import { describe, it, expect } from 'vitest';
import { BLOCK_SCHEMAS, BLOCK_DEFAULTS, safeValidateSchema } from './schemas';
describe('registry shape', () => {
it('has the expected 11 block types', () => {
const types = Object.keys(BLOCK_SCHEMAS).sort();
expect(types).toEqual([
'analytics',
'columns',
'cta',
'faq',
'form',
'gallery',
'hero',
'image',
'moduleEmbed',
'richText',
'spacer',
]);
});
it('every block has matching defaults', () => {
const schemaTypes = Object.keys(BLOCK_SCHEMAS).sort();
const defaultTypes = Object.keys(BLOCK_DEFAULTS).sort();
expect(schemaTypes).toEqual(defaultTypes);
});
it('every block-default passes its own schema', () => {
for (const [type, defaults] of Object.entries(BLOCK_DEFAULTS)) {
const result = safeValidateSchema(type, defaults);
expect(result.success, `defaults for ${type}`).toBe(true);
}
});
it('safeValidateSchema returns error for unknown type', () => {
const result = safeValidateSchema('not-a-block', {});
expect(result.success).toBe(false);
});
});
describe('hero', () => {
it('accepts minimal valid props', () => {
expect(safeValidateSchema('hero', { title: 'Hello' }).success).toBe(true);
});
it('requires non-empty title', () => {
expect(safeValidateSchema('hero', { title: '' }).success).toBe(false);
});
it('rejects invalid align enum', () => {
expect(safeValidateSchema('hero', { title: 'Hi', align: 'justify' }).success).toBe(false);
});
it('caps title length at 240', () => {
expect(safeValidateSchema('hero', { title: 'a'.repeat(241) }).success).toBe(false);
});
});
describe('richText', () => {
it('accepts empty content (renders placeholder in edit)', () => {
expect(safeValidateSchema('richText', { content: '' }).success).toBe(true);
});
it('caps content at 10k chars', () => {
expect(safeValidateSchema('richText', { content: 'x'.repeat(10_001) }).success).toBe(false);
});
it('rejects invalid size', () => {
expect(safeValidateSchema('richText', { size: 'huge' }).success).toBe(false);
});
});
describe('cta', () => {
it('requires non-empty buttonLabel', () => {
expect(safeValidateSchema('cta', { buttonLabel: '' }).success).toBe(false);
});
it('accepts all variants', () => {
for (const variant of ['primary', 'secondary', 'ghost']) {
expect(safeValidateSchema('cta', { buttonLabel: 'Go', variant }).success).toBe(true);
}
});
});
describe('image', () => {
it('accepts empty url (placeholder in edit)', () => {
expect(safeValidateSchema('image', {}).success).toBe(true);
});
it('caps url at 1024', () => {
expect(safeValidateSchema('image', { url: 'x'.repeat(1025) }).success).toBe(false);
});
it('accepts every declared aspectRatio', () => {
for (const aspectRatio of ['auto', '16:9', '4:3', '1:1', '21:9']) {
expect(safeValidateSchema('image', { aspectRatio }).success).toBe(true);
}
});
});
describe('gallery', () => {
it('accepts empty image list', () => {
expect(safeValidateSchema('gallery', { images: [] }).success).toBe(true);
});
it('caps at 60 images', () => {
const images = Array.from({ length: 61 }, () => ({ url: 'https://x' }));
expect(safeValidateSchema('gallery', { images }).success).toBe(false);
});
it('requires url on each image', () => {
const result = safeValidateSchema('gallery', { images: [{ altText: 'x' }] });
expect(result.success).toBe(false);
});
it('accepts only columns 2/3/4', () => {
for (const columns of [2, 3, 4]) {
expect(safeValidateSchema('gallery', { columns }).success).toBe(true);
}
expect(safeValidateSchema('gallery', { columns: 1 }).success).toBe(false);
expect(safeValidateSchema('gallery', { columns: 5 }).success).toBe(false);
});
});
describe('faq', () => {
it('accepts items with question + answer', () => {
const result = safeValidateSchema('faq', {
items: [{ question: 'Q?', answer: 'A.' }],
});
expect(result.success).toBe(true);
});
it('rejects empty question', () => {
const result = safeValidateSchema('faq', {
items: [{ question: '', answer: 'A.' }],
});
expect(result.success).toBe(false);
});
it('caps answer at 2000 chars', () => {
const result = safeValidateSchema('faq', {
items: [{ question: 'Q', answer: 'a'.repeat(2001) }],
});
expect(result.success).toBe(false);
});
});
describe('form', () => {
function validField(overrides: Record<string, unknown> = {}) {
return {
name: 'name',
label: 'Name',
type: 'text',
required: true,
placeholder: '',
helpText: '',
maxLength: 100,
...overrides,
};
}
it('requires at least 1 field', () => {
expect(safeValidateSchema('form', { fields: [] }).success).toBe(false);
});
it('caps at 20 fields', () => {
const fields = Array.from({ length: 21 }, (_, i) => validField({ name: `f${i}` }));
expect(safeValidateSchema('form', { fields }).success).toBe(false);
});
it('rejects field name with hyphen', () => {
expect(
safeValidateSchema('form', { fields: [validField({ name: 'has-hyphen' })] }).success
).toBe(false);
});
it('rejects field name starting with digit', () => {
expect(safeValidateSchema('form', { fields: [validField({ name: '1foo' })] }).success).toBe(
false
);
});
it('accepts all declared field types', () => {
for (const type of ['text', 'email', 'tel', 'url', 'textarea', 'number']) {
expect(
safeValidateSchema('form', { fields: [validField({ type })] }).success,
`type ${type}`
).toBe(true);
}
});
});
describe('moduleEmbed', () => {
it('accepts declared sources', () => {
for (const source of ['picture.board', 'library.entries']) {
expect(safeValidateSchema('moduleEmbed', { source }).success).toBe(true);
}
});
it('rejects unknown source', () => {
expect(safeValidateSchema('moduleEmbed', { source: 'spotify' }).success).toBe(false);
});
it('accepts maxItems 1..48', () => {
expect(safeValidateSchema('moduleEmbed', { maxItems: 1 }).success).toBe(true);
expect(safeValidateSchema('moduleEmbed', { maxItems: 48 }).success).toBe(true);
expect(safeValidateSchema('moduleEmbed', { maxItems: 0 }).success).toBe(false);
expect(safeValidateSchema('moduleEmbed', { maxItems: 49 }).success).toBe(false);
});
});
describe('analytics', () => {
it('accepts both providers', () => {
for (const provider of ['plausible', 'umami']) {
expect(safeValidateSchema('analytics', { provider }).success).toBe(true);
}
});
it('rejects unknown provider', () => {
expect(safeValidateSchema('analytics', { provider: 'ga4' }).success).toBe(false);
});
});
describe('columns', () => {
it('accepts count 2 and 3', () => {
expect(safeValidateSchema('columns', { count: 2 }).success).toBe(true);
expect(safeValidateSchema('columns', { count: 3 }).success).toBe(true);
});
it('rejects other counts', () => {
expect(safeValidateSchema('columns', { count: 1 }).success).toBe(false);
expect(safeValidateSchema('columns', { count: 4 }).success).toBe(false);
});
it('accepts all align enum values', () => {
for (const align of ['start', 'center', 'stretch']) {
expect(safeValidateSchema('columns', { align }).success).toBe(true);
}
});
});
describe('spacer', () => {
it('accepts all sizes', () => {
for (const size of ['sm', 'md', 'lg', 'xl']) {
expect(safeValidateSchema('spacer', { size }).success).toBe(true);
}
});
it('rejects invalid size', () => {
expect(safeValidateSchema('spacer', { size: 'xxl' }).success).toBe(false);
});
});

View file

@ -0,0 +1,62 @@
/**
* Pure-Zod schema aggregation no Svelte components imported here.
*
* This side-channel lets tests + server-side validators pull every
* block's Zod schema without triggering the .svelte imports that
* `registry.ts` needs (which drag in Svelte runtime and trip up
* plain node/bun vitest runners).
*
* Keep the two in lockstep: a new block goes into both.
*/
import { HeroSchema, HERO_DEFAULTS } from './hero/schema';
import { RichTextSchema, RICH_TEXT_DEFAULTS } from './richText/schema';
import { CtaSchema, CTA_DEFAULTS } from './cta/schema';
import { ImageSchema, IMAGE_DEFAULTS } from './image/schema';
import { GallerySchema, GALLERY_DEFAULTS } from './gallery/schema';
import { FaqSchema, FAQ_DEFAULTS } from './faq/schema';
import { FormSchema, FORM_DEFAULTS } from './form/schema';
import { ModuleEmbedSchema, MODULE_EMBED_DEFAULTS } from './moduleEmbed/schema';
import { AnalyticsSchema, ANALYTICS_DEFAULTS } from './analytics/schema';
import { ColumnsSchema, COLUMNS_DEFAULTS } from './columns/schema';
import { SpacerSchema, SPACER_DEFAULTS } from './spacer/schema';
import type { ZodTypeAny } from 'zod';
export const BLOCK_SCHEMAS: Record<string, ZodTypeAny> = {
hero: HeroSchema,
richText: RichTextSchema,
cta: CtaSchema,
image: ImageSchema,
gallery: GallerySchema,
faq: FaqSchema,
form: FormSchema,
moduleEmbed: ModuleEmbedSchema,
analytics: AnalyticsSchema,
columns: ColumnsSchema,
spacer: SpacerSchema,
};
export const BLOCK_DEFAULTS: Record<string, unknown> = {
hero: HERO_DEFAULTS,
richText: RICH_TEXT_DEFAULTS,
cta: CTA_DEFAULTS,
image: IMAGE_DEFAULTS,
gallery: GALLERY_DEFAULTS,
faq: FAQ_DEFAULTS,
form: FORM_DEFAULTS,
moduleEmbed: MODULE_EMBED_DEFAULTS,
analytics: ANALYTICS_DEFAULTS,
columns: COLUMNS_DEFAULTS,
spacer: SPACER_DEFAULTS,
};
export function safeValidateSchema(
type: string,
props: unknown
): { success: true; data: unknown } | { success: false; error: unknown } {
const schema = BLOCK_SCHEMAS[type];
if (!schema) return { success: false, error: new Error(`Unknown block type "${type}"`) };
const parsed = schema.safeParse(props);
if (parsed.success) return { success: true, data: parsed.data };
return { success: false, error: parsed.error };
}

View file

@ -0,0 +1,119 @@
/**
* Theme resolver + CSS-var serializer tests.
*/
import { describe, it, expect } from 'vitest';
import {
THEME_PRESETS,
PRESET_LABELS,
CLASSIC_LIGHT,
MODERN_DARK,
WARM,
resolveTheme,
themeCssVars,
type ThemePreset,
} from './index';
describe('THEME_PRESETS', () => {
it('has exactly classic / modern / warm', () => {
expect(Object.keys(THEME_PRESETS).sort()).toEqual(['classic', 'modern', 'warm']);
});
it('exposes PRESET_LABELS for every preset', () => {
for (const preset of Object.keys(THEME_PRESETS) as ThemePreset[]) {
expect(PRESET_LABELS[preset]).toBeTruthy();
}
});
it('each preset has all required tokens', () => {
const required = [
'primary',
'primaryFg',
'background',
'foreground',
'surface',
'border',
'muted',
'fontFamily',
'headingFontFamily',
'radius',
];
for (const [name, tokens] of Object.entries(THEME_PRESETS)) {
for (const key of required) {
expect((tokens as Record<string, unknown>)[key], `${name}.${key}`).toBeTypeOf('string');
}
}
});
it('classic / modern / warm share the same set of keys (parity check)', () => {
const keys = (o: object) => Object.keys(o).sort().join(',');
expect(keys(CLASSIC_LIGHT)).toBe(keys(MODERN_DARK));
expect(keys(CLASSIC_LIGHT)).toBe(keys(WARM));
});
});
describe('resolveTheme', () => {
it('returns preset tokens as-is when no overrides given', () => {
const resolved = resolveTheme('classic');
expect(resolved).toEqual(CLASSIC_LIGHT);
});
it('returns preset tokens when overrides is an empty object', () => {
const resolved = resolveTheme('modern', {});
expect(resolved).toEqual(MODERN_DARK);
});
it('overrides primary without touching the rest', () => {
const resolved = resolveTheme('classic', { primary: '#ff0080' });
expect(resolved.primary).toBe('#ff0080');
expect(resolved.background).toBe(CLASSIC_LIGHT.background);
expect(resolved.foreground).toBe(CLASSIC_LIGHT.foreground);
});
it('overrides multiple tokens simultaneously', () => {
const resolved = resolveTheme('warm', {
primary: '#111',
background: '#222',
foreground: '#333',
});
expect(resolved.primary).toBe('#111');
expect(resolved.background).toBe('#222');
expect(resolved.foreground).toBe('#333');
expect(resolved.surface).toBe(WARM.surface); // untouched
});
});
describe('themeCssVars', () => {
it('serializes every token as a CSS custom property', () => {
const css = themeCssVars(CLASSIC_LIGHT);
expect(css).toContain('--wb-primary:');
expect(css).toContain('--wb-primary-fg:');
expect(css).toContain('--wb-bg:');
expect(css).toContain('--wb-fg:');
expect(css).toContain('--wb-surface:');
expect(css).toContain('--wb-border:');
expect(css).toContain('--wb-muted:');
expect(css).toContain('--wb-font:');
expect(css).toContain('--wb-font-heading:');
expect(css).toContain('--wb-radius:');
});
it('substitutes the actual token values', () => {
const css = themeCssVars(CLASSIC_LIGHT);
expect(css).toContain(`--wb-primary:${CLASSIC_LIGHT.primary}`);
expect(css).toContain(`--wb-bg:${CLASSIC_LIGHT.background}`);
});
it('falls back to body font for headings when headingFontFamily is empty', () => {
const tokens = { ...CLASSIC_LIGHT, headingFontFamily: '' };
const css = themeCssVars(tokens);
expect(css).toContain(`--wb-font-heading:${CLASSIC_LIGHT.fontFamily}`);
});
it('separates declarations with semicolons (valid inline CSS)', () => {
const css = themeCssVars(MODERN_DARK);
// All 10 declarations → 9 semicolons between + 0 trailing.
const semicolonCount = (css.match(/;/g) ?? []).length;
expect(semicolonCount).toBe(9);
});
});