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:
579
packages/core/tests/integration/seed-on-conflict.test.ts
Normal file
579
packages/core/tests/integration/seed-on-conflict.test.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Tests for seed --on-conflict modes: skip, update, error
|
||||
*
|
||||
* Verifies that applySeed() correctly handles conflicts when records
|
||||
* already exist in the database.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { applySeed } from "../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../src/seed/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../utils/test-db.js";
|
||||
|
||||
/**
|
||||
* Minimal seed file with one collection, one byline, one redirect, and one section
|
||||
*/
|
||||
function createTestSeed(overrides?: Partial<SeedFile>): SeedFile {
|
||||
return {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
fields: [
|
||||
{ slug: "title", label: "Title", type: "string" },
|
||||
{ slug: "body", label: "Body", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bylines: [
|
||||
{
|
||||
id: "byline-1",
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Doe",
|
||||
bio: "Original bio",
|
||||
},
|
||||
],
|
||||
redirects: [
|
||||
{
|
||||
source: "/old-page",
|
||||
destination: "/new-page",
|
||||
type: 301,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Original description",
|
||||
content: [{ _type: "block", _key: "1" }],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello World", body: "Original body" },
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed file with updated values for all entities
|
||||
*/
|
||||
function createUpdatedSeed(): SeedFile {
|
||||
return {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Blog Posts",
|
||||
labelSingular: "Blog Post",
|
||||
fields: [
|
||||
{ slug: "title", label: "Post Title", type: "string" },
|
||||
{ slug: "body", label: "Post Body", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bylines: [
|
||||
{
|
||||
id: "byline-1",
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Smith",
|
||||
bio: "Updated bio",
|
||||
},
|
||||
],
|
||||
redirects: [
|
||||
{
|
||||
source: "/old-page",
|
||||
destination: "/newer-page",
|
||||
type: 302,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
slug: "hero",
|
||||
title: "Updated Hero",
|
||||
description: "Updated description",
|
||||
content: [{ _type: "block", _key: "2" }],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello World Updated", body: "Updated body" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("applySeed onConflict modes", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("onConflict: skip (default)", () => {
|
||||
it("skips existing collections", async () => {
|
||||
const seed = createTestSeed();
|
||||
// First apply
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
// Second apply with default (skip)
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.collections.skipped).toBe(1);
|
||||
expect(result.collections.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing bylines", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.bylines.created).toBe(0);
|
||||
expect(result.bylines.skipped).toBe(1);
|
||||
expect(result.bylines.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing redirects", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
const result = await applySeed(db, seed);
|
||||
|
||||
expect(result.redirects.created).toBe(0);
|
||||
expect(result.redirects.skipped).toBe(1);
|
||||
expect(result.redirects.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing sections", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
const result = await applySeed(db, seed);
|
||||
|
||||
expect(result.sections.created).toBe(0);
|
||||
expect(result.sections.skipped).toBe(1);
|
||||
expect(result.sections.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing content", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.content.created).toBe(0);
|
||||
expect(result.content.skipped).toBe(1);
|
||||
expect(result.content.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to skip when onConflict is not specified", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
// No onConflict specified -- should default to skip
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.collections.skipped).toBe(1);
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.collections.updated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onConflict: update", () => {
|
||||
it("updates existing collections and fields", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.collections.updated).toBe(1);
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.fields.updated).toBe(2);
|
||||
|
||||
// Verify the collection was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_collections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "posts")
|
||||
.executeTakeFirst();
|
||||
expect(row?.label).toBe("Blog Posts");
|
||||
expect(row?.label_singular).toBe("Blog Post");
|
||||
});
|
||||
|
||||
it("updates existing bylines", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.bylines.updated).toBe(1);
|
||||
expect(result.bylines.created).toBe(0);
|
||||
|
||||
// Verify the byline was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_bylines")
|
||||
.selectAll()
|
||||
.where("slug", "=", "jane-doe")
|
||||
.executeTakeFirst();
|
||||
expect(row?.display_name).toBe("Jane Smith");
|
||||
expect(row?.bio).toBe("Updated bio");
|
||||
});
|
||||
|
||||
it("updates existing redirects", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.redirects.updated).toBe(1);
|
||||
expect(result.redirects.created).toBe(0);
|
||||
|
||||
// Verify the redirect was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_redirects")
|
||||
.selectAll()
|
||||
.where("source", "=", "/old-page")
|
||||
.executeTakeFirst();
|
||||
expect(row?.destination).toBe("/newer-page");
|
||||
expect(row?.type).toBe(302);
|
||||
});
|
||||
|
||||
it("updates existing sections", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.sections.updated).toBe(1);
|
||||
expect(result.sections.created).toBe(0);
|
||||
|
||||
// Verify the section was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_sections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "hero")
|
||||
.executeTakeFirst();
|
||||
expect(row?.title).toBe("Updated Hero");
|
||||
expect(row?.description).toBe("Updated description");
|
||||
});
|
||||
|
||||
it("updates existing content", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.content.updated).toBe(1);
|
||||
expect(result.content.created).toBe(0);
|
||||
|
||||
// Verify the content was actually updated
|
||||
const row = await db
|
||||
.selectFrom("ec_posts" as any)
|
||||
.selectAll()
|
||||
.where("slug", "=", "hello-world")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((row as Record<string, unknown>).title).toBe("Hello World Updated");
|
||||
expect((row as Record<string, unknown>).body).toBe("Updated body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onConflict: error", () => {
|
||||
it("throws on existing collection", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
await expect(
|
||||
applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
onConflict: "error",
|
||||
}),
|
||||
).rejects.toThrow('Conflict: collection "posts" already exists');
|
||||
});
|
||||
|
||||
it("throws on existing byline", async () => {
|
||||
// Seed without collections to get past collections step
|
||||
const seed = createTestSeed({ collections: [] });
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: byline "jane-doe" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing redirect", async () => {
|
||||
const seed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
sections: [],
|
||||
});
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: redirect "/old-page" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing section", async () => {
|
||||
const seed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
});
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: section "hero" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing content", async () => {
|
||||
// First apply creates collections and content
|
||||
const seed = createTestSeed({
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
sections: [],
|
||||
});
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Second apply with only content (collections already exist, skip them)
|
||||
const contentOnlySeed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
sections: [],
|
||||
});
|
||||
await expect(
|
||||
applySeed(db, contentOnlySeed, {
|
||||
includeContent: true,
|
||||
onConflict: "error",
|
||||
}),
|
||||
).rejects.toThrow('Conflict: content "hello-world" in "posts" already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed scenarios", () => {
|
||||
it("creates new records alongside existing ones in update mode", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Add a new content entry to the seed
|
||||
const extendedSeed = createUpdatedSeed();
|
||||
const posts = extendedSeed.content!["posts"];
|
||||
if (!posts) throw new Error("posts missing from seed");
|
||||
posts.push({
|
||||
id: "post-2",
|
||||
slug: "second-post",
|
||||
status: "published",
|
||||
data: { title: "Second Post", body: "New content" },
|
||||
});
|
||||
|
||||
const result = await applySeed(db, extendedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.content.updated).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
});
|
||||
|
||||
it("clears taxonomy assignments on content update when seed removes them", async () => {
|
||||
// Seed with a taxonomy and content that has taxonomy assignments
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
terms: [
|
||||
{ slug: "news", label: "News" },
|
||||
{ slug: "tech", label: "Tech" },
|
||||
],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello" },
|
||||
taxonomies: { categories: ["news", "tech"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Verify both terms are attached
|
||||
const beforeRows = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.execute();
|
||||
expect(beforeRows).toHaveLength(2);
|
||||
|
||||
// Re-apply with only one taxonomy term
|
||||
const updatedSeed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
terms: [
|
||||
{ slug: "news", label: "News" },
|
||||
{ slug: "tech", label: "Tech" },
|
||||
],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello Updated" },
|
||||
taxonomies: { categories: ["tech"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
// Should only have "tech" now, not both
|
||||
const afterRows = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.execute();
|
||||
expect(afterRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears byline assignments on content update when seed removes them", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
bylines: [{ id: "byline-1", slug: "jane-doe", displayName: "Jane Doe" }],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello" },
|
||||
bylines: [{ byline: "byline-1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Verify byline is attached
|
||||
const beforeRows = await db
|
||||
.selectFrom("_emdash_content_bylines")
|
||||
.selectAll()
|
||||
.where("collection_slug", "=", "posts")
|
||||
.execute();
|
||||
expect(beforeRows).toHaveLength(1);
|
||||
|
||||
// Re-apply without bylines on the content entry
|
||||
const updatedSeed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
bylines: [{ id: "byline-1", slug: "jane-doe", displayName: "Jane Doe" }],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello Updated" },
|
||||
// No bylines -- should clear existing
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
// Should have no bylines now
|
||||
const afterRows = await db
|
||||
.selectFrom("_emdash_content_bylines")
|
||||
.selectAll()
|
||||
.where("collection_slug", "=", "posts")
|
||||
.execute();
|
||||
expect(afterRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user