Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
1046
packages/core/tests/unit/seed/apply.test.ts
Normal file
1046
packages/core/tests/unit/seed/apply.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
561
packages/core/tests/unit/seed/media.test.ts
Normal file
561
packages/core/tests/unit/seed/media.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setDefaultDnsResolver } from "../../../src/import/ssrf.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import type { Storage, UploadOptions } from "../../../src/storage/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Regex patterns for file validation
|
||||
const PNG_EXTENSION_REGEX = /\.png$/;
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Bypass DoH so the fetch mock only sees the calls these tests model.
|
||||
let previousResolver: ReturnType<typeof setDefaultDnsResolver> | undefined;
|
||||
beforeAll(() => {
|
||||
previousResolver = setDefaultDnsResolver(async () => ["93.184.216.34"]);
|
||||
});
|
||||
afterAll(() => {
|
||||
setDefaultDnsResolver(previousResolver ?? null);
|
||||
});
|
||||
|
||||
// Create a mock storage that tracks uploads
|
||||
function createMockStorage(): Storage & { uploads: UploadOptions[] } {
|
||||
const uploads: UploadOptions[] = [];
|
||||
|
||||
return {
|
||||
uploads,
|
||||
async upload(options: UploadOptions): Promise<void> {
|
||||
uploads.push(options);
|
||||
},
|
||||
async download(key: string): Promise<{ body: Uint8Array; contentType: string }> {
|
||||
const upload = uploads.find((u) => u.key === key);
|
||||
if (!upload) throw new Error(`Not found: ${key}`);
|
||||
return { body: upload.body, contentType: upload.contentType };
|
||||
},
|
||||
async delete(key: string): Promise<void> {
|
||||
const index = uploads.findIndex((u) => u.key === key);
|
||||
if (index >= 0) uploads.splice(index, 1);
|
||||
},
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return uploads.some((u) => u.key === key);
|
||||
},
|
||||
getPublicUrl(key: string): string {
|
||||
return `https://storage.example.com/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Create a mock response for fetch
|
||||
function createMockResponse(
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
ok = true,
|
||||
status = 200,
|
||||
): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: new Headers({ "content-type": contentType }),
|
||||
arrayBuffer: async () => body.buffer,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
// Simple 1x1 PNG for testing
|
||||
const MOCK_PNG = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a, // PNG signature
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // IHDR length
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52, // IHDR chunk type
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // width: 1
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // height: 1
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // bit depth, color type, etc.
|
||||
0x90,
|
||||
0x77,
|
||||
0x53,
|
||||
0xde, // CRC
|
||||
]);
|
||||
|
||||
// Simple 1x1 JPEG for testing
|
||||
const MOCK_JPEG = new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xe0, // SOI + APP0
|
||||
0x00,
|
||||
0x10, // APP0 length
|
||||
0x4a,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00, // JFIF identifier
|
||||
0x01,
|
||||
0x01, // version
|
||||
0x00, // aspect ratio units
|
||||
0x00,
|
||||
0x01, // X density (1)
|
||||
0x00,
|
||||
0x01, // Y density (1)
|
||||
0x00,
|
||||
0x00, // thumbnail dimensions
|
||||
0xff,
|
||||
0xd9, // EOI
|
||||
]);
|
||||
|
||||
describe("$media seed resolution", () => {
|
||||
let db: Kysely<Database>;
|
||||
let storage: Storage & { uploads: UploadOptions[] };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
storage = createMockStorage();
|
||||
mockFetch.mockReset();
|
||||
|
||||
// Set up a collection with an image field
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "posts", label: "Posts" });
|
||||
await registry.createField("posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
await registry.createField("posts", {
|
||||
slug: "featured_image",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should resolve $media references by downloading and uploading", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(MOCK_PNG, "image/png"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello World",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/image.png",
|
||||
alt: "Test image",
|
||||
filename: "my-image.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "https://mysite.com",
|
||||
});
|
||||
|
||||
expect(result.media.created).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
expect(storage.uploads).toHaveLength(1);
|
||||
|
||||
// Check the upload
|
||||
expect(storage.uploads[0].contentType).toBe("image/png");
|
||||
expect(storage.uploads[0].key).toMatch(PNG_EXTENSION_REGEX);
|
||||
|
||||
// Check the content has resolved ImageValue
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const entry = await contentRepo.findBySlug("posts", "hello");
|
||||
|
||||
// ImageValue stores id (URL is built at runtime by EmDashImage)
|
||||
expect(entry?.data.featured_image).toMatchObject({
|
||||
id: expect.any(String),
|
||||
alt: "Test image",
|
||||
});
|
||||
});
|
||||
|
||||
it("should cache repeated $media URLs", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(MOCK_JPEG, "image/jpeg"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "first",
|
||||
data: {
|
||||
title: "First",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/shared.jpg",
|
||||
alt: "Shared image",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "post-2",
|
||||
slug: "second",
|
||||
data: {
|
||||
title: "Second",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/shared.jpg",
|
||||
alt: "Different alt text",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
// Only downloaded/uploaded once
|
||||
expect(result.media.created).toBe(1);
|
||||
expect(result.media.skipped).toBe(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(storage.uploads).toHaveLength(1);
|
||||
|
||||
// Both entries should have the same src but different alt
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const first = await contentRepo.findBySlug("posts", "first");
|
||||
const second = await contentRepo.findBySlug("posts", "second");
|
||||
|
||||
expect(first?.data.featured_image.src).toBe(second?.data.featured_image.src);
|
||||
expect(first?.data.featured_image.alt).toBe("Shared image");
|
||||
expect(second?.data.featured_image.alt).toBe("Different alt text");
|
||||
});
|
||||
|
||||
it("should skip $media when storage is not configured", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/image.png",
|
||||
alt: "Test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// No storage provided
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.media.skipped).toBe(1);
|
||||
expect(result.media.created).toBe(0);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Image field should be null/undefined (not resolved)
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const entry = await contentRepo.findBySlug("posts", "hello");
|
||||
expect(entry?.data.featured_image).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should handle failed downloads gracefully", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(new Uint8Array(), "", false, 404));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/missing.png",
|
||||
alt: "Missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
expect(result.media.skipped).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
|
||||
// Image field should be null/undefined (not resolved)
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const entry = await contentRepo.findBySlug("posts", "hello");
|
||||
expect(entry?.data.featured_image).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/error.png",
|
||||
alt: "Error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
expect(result.media.skipped).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
});
|
||||
|
||||
it("should generate filename from URL when not specified", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(MOCK_PNG, "image/png"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/path/to/beautiful-sunset.png?size=large",
|
||||
alt: "Sunset",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
// Check media record in database
|
||||
const media = await db.selectFrom("media").selectAll().executeTakeFirst();
|
||||
|
||||
expect(media?.filename).toBe("beautiful-sunset.png");
|
||||
});
|
||||
|
||||
it("should use specified filename", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(MOCK_PNG, "image/png"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/random-id-12345.png",
|
||||
alt: "Custom",
|
||||
filename: "my-custom-name.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
const media = await db.selectFrom("media").selectAll().executeTakeFirst();
|
||||
|
||||
expect(media?.filename).toBe("my-custom-name.png");
|
||||
});
|
||||
|
||||
it("should create media record with correct metadata", async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(MOCK_PNG, "image/png"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/test.png",
|
||||
alt: "Test alt text",
|
||||
caption: "Test caption",
|
||||
filename: "test-image.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
const media = await db.selectFrom("media").selectAll().executeTakeFirst();
|
||||
|
||||
expect(media).toMatchObject({
|
||||
filename: "test-image.png",
|
||||
mime_type: "image/png",
|
||||
alt: "Test alt text",
|
||||
caption: "Test caption",
|
||||
status: "ready",
|
||||
});
|
||||
expect(media?.storage_key).toMatch(PNG_EXTENSION_REGEX);
|
||||
});
|
||||
|
||||
it("should resolve nested $media in arrays", async () => {
|
||||
// Set up a collection with a json field for gallery
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createField("posts", {
|
||||
slug: "gallery",
|
||||
label: "Gallery",
|
||||
type: "json",
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(createMockResponse(MOCK_PNG, "image/png"))
|
||||
.mockResolvedValueOnce(createMockResponse(MOCK_JPEG, "image/jpeg"));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello",
|
||||
gallery: [
|
||||
{
|
||||
$media: {
|
||||
url: "https://example.com/one.png",
|
||||
alt: "Image one",
|
||||
},
|
||||
},
|
||||
{
|
||||
$media: {
|
||||
url: "https://example.com/two.jpg",
|
||||
alt: "Image two",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
storage,
|
||||
baseUrl: "",
|
||||
});
|
||||
|
||||
expect(result.media.created).toBe(2);
|
||||
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const entry = await contentRepo.findBySlug("posts", "hello");
|
||||
|
||||
expect(entry?.data.gallery).toHaveLength(2);
|
||||
// ImageValue stores id (URL is built at runtime by EmDashImage)
|
||||
const gallery = entry?.data.gallery as unknown[] | undefined;
|
||||
expect(gallery?.[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
alt: "Image one",
|
||||
});
|
||||
expect(gallery?.[1]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
alt: "Image two",
|
||||
});
|
||||
});
|
||||
});
|
||||
204
packages/core/tests/unit/seed/skip-media-download.test.ts
Normal file
204
packages/core/tests/unit/seed/skip-media-download.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import type { MediaValue } from "../../../src/media/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Mock fetch globally -- should NOT be called when skipMediaDownload is true
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("applySeed with skipMediaDownload", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
mockFetch.mockReset();
|
||||
|
||||
// Set up a collection with an image field
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "posts", label: "Posts" });
|
||||
await registry.createField("posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
await registry.createField("posts", {
|
||||
slug: "featured_image",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should resolve $media to external URL without downloading", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello",
|
||||
data: {
|
||||
title: "Hello World",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://images.unsplash.com/photo-abc123",
|
||||
alt: "A test image",
|
||||
filename: "test-image.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
skipMediaDownload: true,
|
||||
});
|
||||
|
||||
// Media should be "created" (resolved) but not downloaded
|
||||
expect(result.media.created).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
|
||||
// fetch should NOT have been called
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Check the content has an external MediaValue
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const entry = await contentRepo.findBySlug("posts", "hello");
|
||||
expect(entry).toBeDefined();
|
||||
|
||||
const image = entry!.data.featured_image as MediaValue;
|
||||
expect(image).toBeDefined();
|
||||
expect(image.provider).toBe("external");
|
||||
expect(image.src).toBe("https://images.unsplash.com/photo-abc123");
|
||||
expect(image.alt).toBe("A test image");
|
||||
expect(image.filename).toBe("test-image.jpg");
|
||||
expect(image.id).toBeDefined(); // synthetic ULID
|
||||
});
|
||||
|
||||
it("should not require a storage adapter", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "no-storage",
|
||||
data: {
|
||||
title: "No Storage",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/image.jpg",
|
||||
alt: "Test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// No storage adapter provided -- should work fine with skipMediaDownload
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
skipMediaDownload: true,
|
||||
// Intentionally no storage
|
||||
});
|
||||
|
||||
expect(result.media.created).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should cache external media references by URL", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "first",
|
||||
data: {
|
||||
title: "First Post",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/shared-image.jpg",
|
||||
alt: "First alt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "post-2",
|
||||
slug: "second",
|
||||
data: {
|
||||
title: "Second Post",
|
||||
featured_image: {
|
||||
$media: {
|
||||
url: "https://example.com/shared-image.jpg",
|
||||
alt: "Second alt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
skipMediaDownload: true,
|
||||
});
|
||||
|
||||
// First occurrence created, second from cache (skipped)
|
||||
expect(result.media.created).toBe(1);
|
||||
expect(result.media.skipped).toBe(1);
|
||||
expect(result.content.created).toBe(2);
|
||||
|
||||
// Second entry should use the cached alt override
|
||||
const contentRepo = new ContentRepository(db);
|
||||
const second = await contentRepo.findBySlug("posts", "second");
|
||||
const image = second!.data.featured_image as MediaValue;
|
||||
expect(image.alt).toBe("Second alt");
|
||||
expect(image.src).toBe("https://example.com/shared-image.jpg");
|
||||
});
|
||||
|
||||
it("should handle content with no $media refs when skipMediaDownload is set", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "no-media",
|
||||
data: {
|
||||
title: "No Media",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
skipMediaDownload: true,
|
||||
});
|
||||
|
||||
expect(result.content.created).toBe(1);
|
||||
expect(result.media.created).toBe(0);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
782
packages/core/tests/unit/seed/validate.test.ts
Normal file
782
packages/core/tests/unit/seed/validate.test.ts
Normal file
@@ -0,0 +1,782 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user