import { describe, it, expect } from "vitest"; import type { SeedFile } from "../../../src/seed/types.js"; import { validateSeed } from "../../../src/seed/validate.js"; describe("validateSeed", () => { describe("basic validation", () => { it("should reject non-object input", () => { expect(validateSeed(null)).toMatchObject({ valid: false, errors: ["Seed must be an object"], }); expect(validateSeed("string")).toMatchObject({ valid: false, errors: ["Seed must be an object"], }); }); it("should require version field", () => { const result = validateSeed({}); expect(result.valid).toBe(false); expect(result.errors).toContain("Seed must have a version field"); }); it("should reject unsupported versions", () => { const result = validateSeed({ version: "2" }); expect(result.valid).toBe(false); expect(result.errors).toContain("Unsupported seed version: 2"); }); it("should accept valid minimal seed", () => { const result = validateSeed({ version: "1" }); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); describe("collection validation", () => { it("should require collections to be an array", () => { const result = validateSeed({ version: "1", collections: "not an array", }); expect(result.valid).toBe(false); expect(result.errors).toContain("collections must be an array"); }); it("should require collection slug", () => { const result = validateSeed({ version: "1", collections: [{ label: "Posts", fields: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("collections[0]: slug is required"); }); it("should require collection label", () => { const result = validateSeed({ version: "1", collections: [{ slug: "posts", fields: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("collections[0]: label is required"); }); it("should validate slug format", () => { const result = validateSeed({ version: "1", collections: [{ slug: "My Posts", label: "Posts", fields: [] }], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain("must start with a letter"); }); it("should reject duplicate collection slugs", () => { const result = validateSeed({ version: "1", collections: [ { slug: "posts", label: "Posts", fields: [] }, { slug: "posts", label: "Posts Again", fields: [] }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('collections[1].slug: duplicate collection slug "posts"'); }); it("should require fields to be an array", () => { const result = validateSeed({ version: "1", collections: [{ slug: "posts", label: "Posts", fields: "not array" }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("collections[0].fields: must be an array"); }); it("should validate field properties", () => { const result = validateSeed({ version: "1", collections: [ { slug: "posts", label: "Posts", fields: [{ slug: "title" }], // missing label and type }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain("collections[0].fields[0]: label is required"); expect(result.errors).toContain("collections[0].fields[0]: type is required"); }); it("should reject invalid field types", () => { const result = validateSeed({ version: "1", collections: [ { slug: "posts", label: "Posts", fields: [{ slug: "title", label: "Title", type: "invalid" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain('unsupported field type "invalid"'); }); it("should reject duplicate field slugs", () => { const result = validateSeed({ version: "1", collections: [ { slug: "posts", label: "Posts", fields: [ { slug: "title", label: "Title", type: "string" }, { slug: "title", label: "Title 2", type: "string" }, ], }, ], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain('duplicate field slug "title"'); }); it("should accept valid collection with fields", () => { const result = validateSeed({ version: "1", collections: [ { slug: "posts", label: "Posts", fields: [ { slug: "title", label: "Title", type: "string", required: true }, { slug: "content", label: "Content", type: "portableText" }, ], }, ], }); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); describe("taxonomy validation", () => { it("should require taxonomy name", () => { const result = validateSeed({ version: "1", taxonomies: [{ label: "Categories", hierarchical: true, collections: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("taxonomies[0]: name is required"); }); it("should require taxonomy label", () => { const result = validateSeed({ version: "1", taxonomies: [{ name: "category", hierarchical: true, collections: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("taxonomies[0]: label is required"); }); it("should require hierarchical field", () => { const result = validateSeed({ version: "1", taxonomies: [{ name: "category", label: "Categories", collections: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("taxonomies[0]: hierarchical is required"); }); it("should warn about taxonomy with no collections", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: [], }, ], }); expect(result.valid).toBe(true); expect(result.warnings).toContain( 'taxonomies[0].collections: taxonomy "category" is not assigned to any collections', ); }); it("should reject duplicate taxonomy names", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], }, { name: "category", label: "Categories 2", hierarchical: true, collections: ["posts"], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('taxonomies[1].name: duplicate taxonomy name "category"'); }); it("should validate term properties", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], terms: [{ slug: "news" }], // missing label }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain("taxonomies[0].terms[0]: label is required"); }); it("should reject duplicate term slugs", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], terms: [ { slug: "news", label: "News" }, { slug: "news", label: "News 2" }, ], }, ], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain('duplicate term slug "news"'); }); it("should reject self-referencing parent", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], terms: [{ slug: "news", label: "News", parent: "news" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "taxonomies[0].terms[0].parent: term cannot be its own parent", ); }); it("should reject invalid parent reference", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], terms: [{ slug: "news", label: "News", parent: "nonexistent" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( 'taxonomies[0].terms[0].parent: parent term "nonexistent" not found in taxonomy', ); }); it("should warn about parent on non-hierarchical taxonomy", () => { const result = validateSeed({ version: "1", taxonomies: [ { name: "tag", label: "Tags", hierarchical: false, collections: ["posts"], terms: [{ slug: "news", label: "News", parent: "other" }], }, ], }); expect(result.valid).toBe(true); expect(result.warnings[0]).toContain("is not hierarchical, parent will be ignored"); }); }); describe("menu validation", () => { it("should require menu name and label", () => { const result = validateSeed({ version: "1", menus: [{ items: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("menus[0]: name is required"); expect(result.errors).toContain("menus[0]: label is required"); }); it("should reject duplicate menu names", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [] }, { name: "primary", label: "Primary 2", items: [] }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('menus[1].name: duplicate menu name "primary"'); }); it("should validate menu item types", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [{ type: "invalid" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain('must be "custom", "page", "post"'); }); it("should require url for custom items", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [{ type: "custom", label: "Link" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain("menus[0].items[0]: url is required for custom menu items"); }); it("should require ref for page/post items", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [{ type: "page", label: "About" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "menus[0].items[0]: ref is required for page/post menu items", ); }); it("should validate nested menu items", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [ { type: "custom", url: "/about", label: "About", children: [{ type: "page" }], // missing ref }, ], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "menus[0].items[0].items[0]: ref is required for page/post menu items", ); }); it("should warn about menu refs not in content", () => { const result = validateSeed({ version: "1", menus: [ { name: "primary", label: "Primary", items: [{ type: "page", ref: "about" }], }, ], content: { pages: [{ id: "home", slug: "home", data: { title: "Home" } }], }, }); expect(result.valid).toBe(true); expect(result.warnings).toContain( 'Menu item references content "about" which is not in the seed file', ); }); }); describe("widget area validation", () => { it("should require widget area name and label", () => { const result = validateSeed({ version: "1", widgetAreas: [{ widgets: [] }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("widgetAreas[0]: name is required"); expect(result.errors).toContain("widgetAreas[0]: label is required"); }); it("should reject duplicate widget area names", () => { const result = validateSeed({ version: "1", widgetAreas: [ { name: "sidebar", label: "Sidebar", widgets: [] }, { name: "sidebar", label: "Sidebar 2", widgets: [] }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('widgetAreas[1].name: duplicate widget area name "sidebar"'); }); it("should validate widget types", () => { const result = validateSeed({ version: "1", widgetAreas: [ { name: "sidebar", label: "Sidebar", widgets: [{ type: "invalid" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors[0]).toContain('must be "content", "menu", or "component"'); }); it("should require menuName for menu widgets", () => { const result = validateSeed({ version: "1", widgetAreas: [ { name: "sidebar", label: "Sidebar", widgets: [{ type: "menu", title: "Nav" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "widgetAreas[0].widgets[0]: menuName is required for menu widgets", ); }); it("should require componentId for component widgets", () => { const result = validateSeed({ version: "1", widgetAreas: [ { name: "sidebar", label: "Sidebar", widgets: [{ type: "component", title: "Recent Posts" }], }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "widgetAreas[0].widgets[0]: componentId is required for component widgets", ); }); }); describe("redirect validation", () => { it("should require redirects to be an array", () => { const result = validateSeed({ version: "1", redirects: "not an array", }); expect(result.valid).toBe(false); expect(result.errors).toContain("redirects must be an array"); }); it("should require source and destination", () => { const result = validateSeed({ version: "1", redirects: [{}], }); expect(result.valid).toBe(false); expect(result.errors).toContain("redirects[0]: source is required"); expect(result.errors).toContain("redirects[0]: destination is required"); }); it("should validate redirect source and destination paths", () => { const result = validateSeed({ version: "1", redirects: [{ source: "https://example.com", destination: "//external" }], }); expect(result.valid).toBe(false); expect(result.errors).toContain( "redirects[0].source: must be a path starting with / (no protocol-relative URLs, path traversal, or newlines)", ); expect(result.errors).toContain( "redirects[0].destination: must be a path starting with / (no protocol-relative URLs, path traversal, or newlines)", ); }); it("should validate redirect type", () => { const result = validateSeed({ version: "1", redirects: [{ source: "/old", destination: "/new", type: 303 }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("redirects[0].type: must be 301, 302, 307, or 308"); }); it("should reject duplicate redirect sources", () => { const result = validateSeed({ version: "1", redirects: [ { source: "/old", destination: "/new" }, { source: "/old", destination: "/newer" }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('redirects[1].source: duplicate redirect source "/old"'); }); it("should accept valid redirects", () => { const result = validateSeed({ version: "1", redirects: [ { source: "/old", destination: "/new" }, { source: "/temp", destination: "/next", type: 302, enabled: false }, ], }); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); describe("content validation", () => { it("should require content to be an object", () => { const result = validateSeed({ version: "1", content: [], }); expect(result.valid).toBe(false); expect(result.errors).toContain("content must be an object (collection -> entries)"); }); it("should require content entries to be arrays", () => { const result = validateSeed({ version: "1", content: { posts: "not array" }, }); expect(result.valid).toBe(false); expect(result.errors).toContain("content.posts: must be an array"); }); it("should require entry id and slug", () => { const result = validateSeed({ version: "1", content: { posts: [{ data: { title: "Hello" } }], }, }); expect(result.valid).toBe(false); expect(result.errors).toContain("content.posts[0]: id is required"); expect(result.errors).toContain("content.posts[0]: slug is required"); }); it("should require entry data to be an object", () => { const result = validateSeed({ version: "1", content: { posts: [{ id: "hello", slug: "hello", data: "not object" }], }, }); expect(result.valid).toBe(false); expect(result.errors).toContain("content.posts[0]: data must be an object"); }); it("should reject duplicate entry ids", () => { const result = validateSeed({ version: "1", content: { posts: [ { id: "hello", slug: "hello", data: { title: "Hello" } }, { id: "hello", slug: "hello-2", data: { title: "Hello 2" } }, ], }, }); expect(result.valid).toBe(false); expect(result.errors).toContain( 'content.posts[1].id: duplicate entry id "hello" in collection "posts"', ); }); it("should validate byline references in content entries", () => { const result = validateSeed({ version: "1", bylines: [{ id: "editorial", slug: "editorial", displayName: "Editorial" }], content: { posts: [ { id: "post-1", slug: "hello", data: { title: "Hello" }, bylines: [{ byline: "missing" }], }, ], }, }); expect(result.valid).toBe(false); expect(result.errors).toContain( 'content.posts[0].bylines[0].byline: references unknown byline "missing"', ); }); }); describe("byline validation", () => { it("should require byline id, slug, and displayName", () => { const result = validateSeed({ version: "1", bylines: [{ slug: "editorial" }], }); expect(result.valid).toBe(false); expect(result.errors).toContain("bylines[0]: id is required"); expect(result.errors).toContain("bylines[0]: displayName is required"); }); it("should reject duplicate byline ids and slugs", () => { const result = validateSeed({ version: "1", bylines: [ { id: "editorial", slug: "editorial", displayName: "Editorial" }, { id: "editorial", slug: "editorial", displayName: "Editorial 2" }, ], }); expect(result.valid).toBe(false); expect(result.errors).toContain('bylines[1].id: duplicate byline id "editorial"'); expect(result.errors).toContain('bylines[1].slug: duplicate byline slug "editorial"'); }); }); describe("full seed validation", () => { it("should accept a complete valid seed", () => { const seed: SeedFile = { version: "1", meta: { name: "Blog Starter", description: "A simple blog template", }, settings: { title: "My Blog", tagline: "Thoughts and ideas", }, collections: [ { slug: "posts", label: "Posts", fields: [ { slug: "title", label: "Title", type: "string", required: true }, { slug: "content", label: "Content", type: "portableText" }, ], }, { slug: "pages", label: "Pages", fields: [ { slug: "title", label: "Title", type: "string", required: true }, { slug: "content", label: "Content", type: "portableText" }, ], }, ], taxonomies: [ { name: "category", label: "Categories", hierarchical: true, collections: ["posts"], terms: [ { slug: "news", label: "News" }, { slug: "tutorials", label: "Tutorials" }, ], }, ], menus: [ { name: "primary", label: "Primary Navigation", items: [ { type: "custom", url: "/", label: "Home" }, { type: "page", ref: "about" }, ], }, ], redirects: [ { source: "/old-about", destination: "/about" }, { source: "/legacy-feed", destination: "/rss.xml", type: 308, groupName: "import" }, ], widgetAreas: [ { name: "sidebar", label: "Sidebar", widgets: [ { type: "component", componentId: "core:recent-posts", props: { count: 5 }, }, ], }, ], content: { pages: [ { id: "about", slug: "about", status: "published", data: { title: "About", content: [] }, }, ], posts: [ { id: "hello", slug: "hello-world", status: "published", data: { title: "Hello World", content: [] }, taxonomies: { category: ["news"] }, }, ], }, }; const result = validateSeed(seed); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); });