mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
66bfcb3996
commit
f20ace0358
10 changed files with 844 additions and 39 deletions
|
|
@ -24,6 +24,7 @@ import { requireTier, type AuthVariables } from '@mana/shared-hono';
|
|||
import { errorResponse, validationError } from '../../lib/responses';
|
||||
import { websiteDomainVerifyTotal } from '../../lib/metrics';
|
||||
import { db, customDomains } from './schema';
|
||||
import { isValidHostname } from './reserved-slugs';
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
|
|
@ -36,36 +37,6 @@ routes.use('/sites/*/domains/*', requireTier('founder'));
|
|||
const DNS_TARGET = process.env.WEBSITE_DNS_TARGET ?? 'custom.mana.how';
|
||||
const CHALLENGE_PREFIX = '_mana-challenge';
|
||||
|
||||
// Conservative hostname whitelist — lowercase letters, digits, dots,
|
||||
// hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal
|
||||
// names. Not a full RFC-compliant parser — just a sanity check before
|
||||
// we hand the string to dns.resolve.
|
||||
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
|
||||
|
||||
// Reserved hostnames that must not be user-bound. `mana.how` itself +
|
||||
// every app subdomain (todo, chat, …) lives here so a user can't point
|
||||
// their CNAME at the app's root.
|
||||
const RESERVED_HOSTNAMES = new Set([
|
||||
'mana.how',
|
||||
'www.mana.how',
|
||||
'api.mana.how',
|
||||
'auth.mana.how',
|
||||
'app.mana.how',
|
||||
'admin.mana.how',
|
||||
'custom.mana.how',
|
||||
'events.mana.how',
|
||||
'research.mana.how',
|
||||
]);
|
||||
|
||||
function isValidHostname(raw: string): boolean {
|
||||
if (!raw) return false;
|
||||
const lower = raw.toLowerCase().trim();
|
||||
if (!HOSTNAME_RE.test(lower)) return false;
|
||||
if (RESERVED_HOSTNAMES.has(lower)) return false;
|
||||
if (lower.endsWith('.mana.how')) return false; // reserved root
|
||||
return true;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes)
|
||||
|
|
|
|||
100
apps/api/src/modules/website/reserved-slugs.test.ts
Normal file
100
apps/api/src/modules/website/reserved-slugs.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Slug + hostname validator tests. Pure — no DB, no DNS.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidSlug, RESERVED_SLUGS, isValidHostname, RESERVED_HOSTNAMES } from './reserved-slugs';
|
||||
|
||||
describe('isValidSlug (server)', () => {
|
||||
it('accepts 2-40 lowercase alphanumerics + hyphens', () => {
|
||||
for (const s of ['ab', 'hello', 'hello-world', '1234', 'a'.repeat(40)]) {
|
||||
expect(isValidSlug(s), `"${s}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects single chars, too-long, uppercase, edge hyphens, non-ASCII', () => {
|
||||
for (const s of ['', 'a', 'a'.repeat(41), 'Hello', '-x', 'x-', 'a b', 'ä']) {
|
||||
expect(isValidSlug(s), `"${s}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects every entry in RESERVED_SLUGS', () => {
|
||||
for (const slug of RESERVED_SLUGS) {
|
||||
expect(isValidSlug(slug), `reserved "${slug}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reserved-list covers the minimum app routes', () => {
|
||||
for (const must of ['app', 'api', 'auth', 'admin', 's', 'www']) {
|
||||
expect(RESERVED_SLUGS.includes(must), `${must} must be reserved`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidHostname', () => {
|
||||
it('accepts reasonable external domains', () => {
|
||||
const valid = [
|
||||
'example.com',
|
||||
'portfolio.example.com',
|
||||
'my-site.deine-domain.de',
|
||||
'a.b.c.d.example.com', // deep subdomain
|
||||
'abc.io',
|
||||
];
|
||||
for (const h of valid) {
|
||||
expect(isValidHostname(h), `"${h}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed hostnames', () => {
|
||||
const invalid = [
|
||||
'',
|
||||
'localhost', // no dot
|
||||
'example', // no TLD
|
||||
'.example.com', // leading dot
|
||||
'example..com', // double dot
|
||||
'exa mple.com', // space
|
||||
'example.com.', // trailing dot
|
||||
'-example.com', // leading hyphen
|
||||
'example-.com', // trailing hyphen on label
|
||||
'EXAMPLE.COM', // uppercase — we lowercase but still must match; let's verify current behaviour
|
||||
'192.168.1.1', // IP rejected (TLD must be letters)
|
||||
'foo.1', // TLD needs 2+ letters
|
||||
];
|
||||
for (const h of invalid) {
|
||||
// EXAMPLE.COM currently passes because isValidHostname lowercases
|
||||
// before regex. That's intentional.
|
||||
if (h === 'EXAMPLE.COM') {
|
||||
expect(isValidHostname(h)).toBe(true);
|
||||
continue;
|
||||
}
|
||||
expect(isValidHostname(h), `"${h}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects anything under .mana.how', () => {
|
||||
for (const h of [
|
||||
'mana.how',
|
||||
'www.mana.how',
|
||||
'todo.mana.how',
|
||||
'random-user.mana.how',
|
||||
'a.b.c.mana.how',
|
||||
]) {
|
||||
expect(isValidHostname(h), `"${h}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects every RESERVED_HOSTNAMES entry', () => {
|
||||
for (const h of RESERVED_HOSTNAMES) {
|
||||
expect(isValidHostname(h), `reserved "${h}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('trims whitespace before validating', () => {
|
||||
expect(isValidHostname(' example.com ')).toBe(true);
|
||||
});
|
||||
|
||||
it('respects length limit (253 chars RFC)', () => {
|
||||
const tooLong = 'a.'.repeat(130) + 'com'; // > 253
|
||||
expect(isValidHostname(tooLong)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
/**
|
||||
* Reserved slugs — server-authoritative list. The client carries a
|
||||
* mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts`
|
||||
* for fast pre-flight UX, but this list is the one that matters at
|
||||
* publish time.
|
||||
* Reserved slugs + hostname validation — server-authoritative lists.
|
||||
* The client carries mirror copies for fast pre-flight UX, but these
|
||||
* are the ones that matter at publish time.
|
||||
*
|
||||
* Rule: any slug that would shadow a SvelteKit route or collide with
|
||||
* a well-known subdomain goes here. When a new top-level route is
|
||||
* added, append its segment here in the same PR.
|
||||
* Rule (slugs): any slug that would shadow a SvelteKit route or
|
||||
* collide with a well-known subdomain goes in RESERVED_SLUGS. When a
|
||||
* new top-level route is added, append its segment here in the same
|
||||
* PR.
|
||||
*
|
||||
* Rule (hostnames): only hostnames the user genuinely owns should be
|
||||
* bindable. Anything ending in `.mana.how`, or the well-known CF /
|
||||
* auth / app endpoints, stays reserved.
|
||||
*/
|
||||
|
||||
// ── Slugs ───────────────────────────────────────────────
|
||||
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
'app',
|
||||
'api',
|
||||
|
|
@ -33,10 +39,52 @@ export const RESERVED_SLUGS: readonly string[] = [
|
|||
];
|
||||
|
||||
/** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */
|
||||
const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
if (!SLUG_REGEX.test(slug)) return false;
|
||||
if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Hostnames ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Conservative hostname regex — lowercase letters, digits, dots,
|
||||
* hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal
|
||||
* names. Not a full RFC-compliant parser — a sanity check before we
|
||||
* hand the string to dns.resolve.
|
||||
*/
|
||||
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
|
||||
|
||||
/**
|
||||
* Reserved hostnames that must not be user-bound. mana.how itself +
|
||||
* every app subdomain (todo, chat, …) lives here so a user can't
|
||||
* point their CNAME at the app's root.
|
||||
*/
|
||||
export const RESERVED_HOSTNAMES: ReadonlySet<string> = new Set([
|
||||
'mana.how',
|
||||
'www.mana.how',
|
||||
'api.mana.how',
|
||||
'auth.mana.how',
|
||||
'app.mana.how',
|
||||
'admin.mana.how',
|
||||
'custom.mana.how',
|
||||
'events.mana.how',
|
||||
'research.mana.how',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True if `raw` is a syntactically valid hostname that isn't reserved
|
||||
* and doesn't live under `.mana.how` (which is ours end-to-end —
|
||||
* subdomain-publish handles `{slug}.mana.how` specifically, users
|
||||
* should bring an external domain to the custom-domain flow).
|
||||
*/
|
||||
export function isValidHostname(raw: string): boolean {
|
||||
if (!raw) return false;
|
||||
const lower = raw.toLowerCase().trim();
|
||||
if (!HOSTNAME_RE.test(lower)) return false;
|
||||
if (RESERVED_HOSTNAMES.has(lower)) return false;
|
||||
if (lower.endsWith('.mana.how')) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
108
apps/mana/apps/web/src/lib/modules/website/constants.test.ts
Normal file
108
apps/mana/apps/web/src/lib/modules/website/constants.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Tests for the slug / path validators. Pure regex — no I/O.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidSlug, isReservedSlug, SLUG_REGEX, RESERVED_SLUGS } from './constants';
|
||||
import { isValidPath } from './stores/pages.svelte';
|
||||
|
||||
describe('slug validation', () => {
|
||||
it('accepts valid slugs', () => {
|
||||
const valid = [
|
||||
'ab', // minimum length
|
||||
'hello',
|
||||
'hello-world',
|
||||
'portfolio-2026',
|
||||
'a-b-c',
|
||||
'1234',
|
||||
'a'.repeat(40), // maximum length
|
||||
];
|
||||
for (const s of valid) {
|
||||
expect(isValidSlug(s), `"${s}" should be valid`).toBe(true);
|
||||
expect(SLUG_REGEX.test(s), `"${s}" should match regex`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid formats', () => {
|
||||
const invalid = [
|
||||
'', // empty
|
||||
'a', // too short
|
||||
'a'.repeat(41), // too long
|
||||
'Hello', // uppercase
|
||||
'-abc', // leading hyphen
|
||||
'abc-', // trailing hyphen
|
||||
'ab--cd', // double hyphen is technically OK by regex — sanity-check inverted expectation below
|
||||
'ab cd', // space
|
||||
'ab.cd', // dot
|
||||
'ab_cd', // underscore
|
||||
'ab/cd', // slash
|
||||
'üöä', // non-ASCII
|
||||
'🌟', // emoji
|
||||
];
|
||||
// Double-hyphen: the plan's regex ALLOWS it (no explicit ban).
|
||||
// If we ever forbid it, remove from this list and move to valid.
|
||||
const expected = invalid.filter((s) => s !== 'ab--cd');
|
||||
for (const s of expected) {
|
||||
expect(isValidSlug(s), `"${s}" should be invalid`).toBe(false);
|
||||
}
|
||||
expect(isValidSlug('ab--cd'), 'double-hyphen currently allowed').toBe(true);
|
||||
});
|
||||
|
||||
it('rejects reserved slugs even if format is valid', () => {
|
||||
for (const reserved of RESERVED_SLUGS) {
|
||||
expect(isReservedSlug(reserved)).toBe(true);
|
||||
expect(isValidSlug(reserved), `"${reserved}" is reserved`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reserved-slug check is case-insensitive', () => {
|
||||
expect(isReservedSlug('API')).toBe(true);
|
||||
expect(isReservedSlug('Api')).toBe(true);
|
||||
expect(isReservedSlug('admin')).toBe(true);
|
||||
});
|
||||
|
||||
it('covers the minimum reserved names that would shadow app routes', () => {
|
||||
const mustHave = ['app', 'api', 'auth', 'admin', 's', 'www'];
|
||||
for (const name of mustHave) {
|
||||
expect(RESERVED_SLUGS.includes(name), `${name} must be reserved`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('page path validation', () => {
|
||||
it('accepts valid paths', () => {
|
||||
const valid = [
|
||||
'/',
|
||||
'/about',
|
||||
'/about-us',
|
||||
'/docs/getting-started',
|
||||
'/blog/2026/04/hello',
|
||||
'/a',
|
||||
'/a/b/c/d',
|
||||
];
|
||||
for (const p of valid) {
|
||||
expect(isValidPath(p), `"${p}" should be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid paths', () => {
|
||||
const invalid = [
|
||||
'', // must start with /
|
||||
'about', // no leading slash
|
||||
'/about/', // trailing slash (per current regex)
|
||||
'/About', // uppercase
|
||||
'/foo bar', // space
|
||||
'/foo.html', // dot
|
||||
'/foo//bar', // double slash
|
||||
'//', // just slashes
|
||||
'/ä', // non-ASCII
|
||||
];
|
||||
for (const p of invalid) {
|
||||
expect(isValidPath(p), `"${p}" should be invalid`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('root path "/" is always valid', () => {
|
||||
expect(isValidPath('/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -36,8 +36,12 @@ export function isReservedSlug(slug: string): boolean {
|
|||
/**
|
||||
* Slug regex — lowercase alphanumerics + hyphens, 2-40 chars, no leading
|
||||
* or trailing hyphen. Mirrored in the backend for authoritative checks.
|
||||
*
|
||||
* Single-char slugs are forbidden: hard to read, reserved-list + TLD-
|
||||
* collision risk scales with shortness, and `a.mana.how` is the kind
|
||||
* of thing every app would need a special exception for. Min 2.
|
||||
*/
|
||||
export const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
export const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
return SLUG_REGEX.test(slug) && !isReservedSlug(slug);
|
||||
|
|
|
|||
|
|
@ -156,4 +156,49 @@ describe('buildBlockTree — orphan handling', () => {
|
|||
const tree = buildBlockTree(blocks);
|
||||
expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']);
|
||||
});
|
||||
|
||||
it('drops self-parent blocks (cycle of length 1)', () => {
|
||||
const blocks: LocalWebsiteBlock[] = [
|
||||
localBlock('ok', 1024),
|
||||
localBlock('loop', 2048, { parentBlockId: 'loop' }),
|
||||
];
|
||||
// `loop` references itself as parent. byId.has('loop') is true, so
|
||||
// the orphan-drop doesn't catch it; children-walk from `loop`
|
||||
// recurses forever IF we don't guard. Current implementation
|
||||
// should either drop it or keep a cycle-free view — verify no
|
||||
// stack overflow + only the well-formed block lands at root.
|
||||
const tree = buildBlockTree(blocks);
|
||||
// The self-referencing block will be reachable as a child of
|
||||
// itself; what we assert is that the walk doesn't infinite-loop
|
||||
// and the `ok` block is always at top-level.
|
||||
expect(tree.find((b) => b.id === 'ok')).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles 3-level nesting without losing ancestors', () => {
|
||||
const blocks: LocalWebsiteBlock[] = [
|
||||
localBlock('grandparent', 1024),
|
||||
localBlock('parent', 2048, { parentBlockId: 'grandparent' }),
|
||||
localBlock('child', 3072, { parentBlockId: 'parent' }),
|
||||
];
|
||||
const tree = buildBlockTree(blocks);
|
||||
expect(tree.length).toBe(1);
|
||||
expect(tree[0]!.id).toBe('grandparent');
|
||||
expect(tree[0]!.children.length).toBe(1);
|
||||
expect(tree[0]!.children[0]!.id).toBe('parent');
|
||||
expect(tree[0]!.children[0]!.children.length).toBe(1);
|
||||
expect(tree[0]!.children[0]!.children[0]!.id).toBe('child');
|
||||
});
|
||||
|
||||
it('returns an empty array when all blocks are orphans', () => {
|
||||
const blocks: LocalWebsiteBlock[] = [
|
||||
localBlock('o1', 1024, { parentBlockId: 'nope' }),
|
||||
localBlock('o2', 2048, { parentBlockId: 'also-nope' }),
|
||||
];
|
||||
const tree = buildBlockTree(blocks);
|
||||
expect(tree).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for no input', () => {
|
||||
expect(buildBlockTree([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
109
apps/mana/apps/web/src/lib/modules/website/templates.test.ts
Normal file
109
apps/mana/apps/web/src/lib/modules/website/templates.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Template apply + snapshot tests.
|
||||
*
|
||||
* Verifies that each of the 4 bundled templates:
|
||||
* 1. clones into a fresh site with new UUIDs (no template-localId
|
||||
* leaks into Dexie rows)
|
||||
* 2. produces the expected page + block counts
|
||||
* 3. preserves parentBlockId chains when a template uses containers
|
||||
* 4. populates navConfig with template page titles + paths
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
|
||||
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock('./embeds', () => ({
|
||||
resolveEmbed: vi.fn(async () => ({ items: [], resolvedAt: '2026-04-23T00:00:00.000Z' })),
|
||||
}));
|
||||
|
||||
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
|
||||
import { sitesStore } from './stores/sites.svelte';
|
||||
import { SITE_TEMPLATES } from './templates';
|
||||
|
||||
describe('sitesStore.applyTemplate — per-template round-trip', () => {
|
||||
beforeEach(async () => {
|
||||
await websitesTable.clear();
|
||||
await websitePagesTable.clear();
|
||||
await websiteBlocksTable.clear();
|
||||
});
|
||||
|
||||
it.each(SITE_TEMPLATES.map((t) => [t.id] as const))(
|
||||
'clones "%s" into a fresh site with new UUIDs',
|
||||
async (templateId) => {
|
||||
const tpl = SITE_TEMPLATES.find((t) => t.id === templateId)!;
|
||||
const expectedBlockCount = tpl.pages.reduce((sum, p) => sum + p.blocks.length, 0);
|
||||
|
||||
const { siteId, homePageId } = await sitesStore.applyTemplate(templateId, {
|
||||
slug: `test-${templateId}`,
|
||||
name: `Test ${templateId}`,
|
||||
});
|
||||
|
||||
// Site row exists with fresh id.
|
||||
const site = await websitesTable.get(siteId);
|
||||
expect(site).toBeDefined();
|
||||
expect(site!.slug).toBe(`test-${templateId}`);
|
||||
expect(site!.publishedVersion).toBeNull();
|
||||
|
||||
// Pages: one per template page.
|
||||
const pages = await websitePagesTable.where('siteId').equals(siteId).toArray();
|
||||
expect(pages.length).toBe(tpl.pages.length);
|
||||
|
||||
// Home page id matches the first page (path '/') when present.
|
||||
const home = pages.find((p) => p.path === '/');
|
||||
expect(home?.id).toBe(homePageId);
|
||||
|
||||
// Blocks: total across all pages matches template total.
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const blocks = await websiteBlocksTable.where('pageId').anyOf(pageIds).toArray();
|
||||
expect(blocks.length).toBe(expectedBlockCount);
|
||||
|
||||
// Every block has a real UUID (not a template localId).
|
||||
for (const block of blocks) {
|
||||
expect(block.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
// No template 'localId' fields leak into the row.
|
||||
expect((block as unknown as { localId?: string }).localId).toBeUndefined();
|
||||
}
|
||||
|
||||
// navConfig gets populated from template pages.
|
||||
expect(site!.navConfig.items.length).toBe(tpl.pages.length);
|
||||
expect(site!.navConfig.items.map((i) => i.pagePath).sort()).toEqual(
|
||||
tpl.pages.map((p) => p.path).sort()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// NOTE: the parentLocalId → parentBlockId remap logic inside
|
||||
// applyTemplate is exercised only when a bundled template uses a
|
||||
// container. None of the four shipped templates (portfolio,
|
||||
// linktree, event, blank) do yet. Once we add smb-corporate
|
||||
// (columns-heavy), add a parent-chain assertion to the .each loop
|
||||
// above. Until then, the behaviour is covered by the manual smoke
|
||||
// test in docs/plans/website-builder-smoketest.md §2 (Columns).
|
||||
|
||||
it('rejects an unknown template id', async () => {
|
||||
await expect(
|
||||
sitesStore.applyTemplate('does-not-exist', { slug: 'oops', name: 'Oops' })
|
||||
).rejects.toThrow(/Unknown template/);
|
||||
});
|
||||
|
||||
it('rejects duplicate slugs in the same space', async () => {
|
||||
await sitesStore.applyTemplate('blank', { slug: 'dup', name: 'First' });
|
||||
await expect(
|
||||
sitesStore.applyTemplate('blank', { slug: 'dup', name: 'Second' })
|
||||
).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('rejects invalid slug format', async () => {
|
||||
await expect(
|
||||
sitesStore.applyTemplate('blank', { slug: 'HAS UPPERCASE', name: 'Bad' })
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
sitesStore.applyTemplate('blank', { slug: 'a', name: 'Too short' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
239
packages/website-blocks/src/schemas.test.ts
Normal file
239
packages/website-blocks/src/schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
packages/website-blocks/src/schemas.ts
Normal file
62
packages/website-blocks/src/schemas.ts
Normal 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 };
|
||||
}
|
||||
119
packages/website-blocks/src/themes/themes.test.ts
Normal file
119
packages/website-blocks/src/themes/themes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue