managarten/packages/website-blocks/src/schemas.test.ts
Till JS ace1b706e6 feat(forms): M8 website-block — formEmbed bindet Mana-Formulare ein
Neuer Block-Type `formEmbed` im Website-Builder
(docs/plans/forms-module.md M8):

- @mana/website-blocks/src/formEmbed/:
  - schema.ts: FormEmbedSchema mit token (32-char base64url) +
    titleOverride + optional resolved-Block (formTitle, fields,
    branching, settings.{submitButtonLabel, successMessage}).
    FormFieldEmbedSchema duplicated leichtgewichtig statt cross-
    package import — website-blocks bleibt self-contained.
  - FormEmbed.svelte: edit/preview rendert Placeholder-Card mit
    Token-Snippet und resolved-Status; public rendert die kompletten
    11 Field-Types inkl. Live-Branching-aware-Render. Submitter-
    Block (Name+Email optional). Submit POSTet an
    /api/v1/forms/public/:token/submit. Lazy-Fallback fetcht
    /api/v1/unlisted/public/:token wenn die publish-resolver-blob
    fehlt. Bot-Honeypot bleibt M8-Polish.
  - FormEmbedInspector.svelte: Token-Input mit base64url-Validierung
    bei blur, optional titleOverride, resolved-Card mit
    Field-Count + Logik-Regel-Count.
- BLOCK_SPECS + BLOCK_SCHEMAS + BLOCK_DEFAULTS um formEmbed
  erweitert. schemas.test.ts erwartet jetzt 12 Block-Types.
- apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts:
  resolveFormEmbed scant formTable nach unlistedToken (linear scan
  ist günstig bei <100 forms pro user, kein Index nötig), dekrypted,
  validiert published-status, gibt resolved-Block zurück.
- publish.ts.resolveEmbedsInTree erweitert um formEmbed-Branch — ruft
  resolveFormEmbed parallel zu resolveEmbed (moduleEmbed) im selben
  Walk.

Trade-offs:
- Token statt formId: bei Token-Rotation (M4b) muss der User den Block
  neu konfigurieren. Der formEmbed-Block-Resolver erkennt das + setzt
  resolved.error; public-Renderer fällt auf lazy-fetch zurück.
- Plaintext stored: das resolved-Blob landet als plaintext im
  public-snapshot, gleiches Trust-Modell wie moduleEmbed (öffentliche
  Website per Definition).

Tests: website-blocks 50/50 grün (12 schema-block-types + per-type
defaults validation). svelte-check 0 errors. forms 26/26 unverändert.

Use-Case: Vereins-Sommerfest. User legt /forms/anmeldung an,
publisht, setzt unlisted, kopiert Token. Im Website-Builder fügt er
einen formEmbed-Block auf der Event-Seite ein, paste Token → bei
Publish wird der Form-Schema inlined → Besucher submitten direkt
auf der Vereins-Website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:38:28 +02:00

240 lines
7.6 KiB
TypeScript

/**
* 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 12 block types', () => {
const types = Object.keys(BLOCK_SCHEMAS).sort();
expect(types).toEqual([
'analytics',
'columns',
'cta',
'faq',
'form',
'formEmbed',
'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);
});
});