Files
emdash-patch-imageupload/packages/core/tests/integration/i18n/i18n.test.ts
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

840 lines
24 KiB
TypeScript

import type { Kysely } from "kysely";
import { sql } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import type { Database } from "../../../src/database/types.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import { FTSManager } from "../../../src/search/fts-manager.js";
import { searchWithDb } from "../../../src/search/query.js";
import { applySeed } from "../../../src/seed/apply.js";
import type { SeedFile } from "../../../src/seed/types.js";
import { validateSeed } from "../../../src/seed/validate.js";
import { createPostFixture } from "../../utils/fixtures.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
describe("i18n (Integration)", () => {
let db: Kysely<Database>;
let repo: ContentRepository;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
repo = new ContentRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
// ─── 1. Migration — i18n columns exist ──────────────────────────
describe("Migration — i18n columns", () => {
it("should have locale and translation_group columns on content tables", async () => {
const result = await sql<{ name: string }>`
PRAGMA table_info(ec_post)
`.execute(db);
const columnNames = result.rows.map((r) => r.name);
expect(columnNames).toContain("locale");
expect(columnNames).toContain("translation_group");
});
it("should default locale to 'en'", async () => {
const result = await sql<{ name: string; dflt_value: string | null }>`
PRAGMA table_info(ec_post)
`.execute(db);
const localeCol = result.rows.find((r) => r.name === "locale");
expect(localeCol).toBeDefined();
expect(localeCol!.dflt_value).toBe("'en'");
});
it("should have translatable column on _emdash_fields", async () => {
const result = await sql<{ name: string }>`
PRAGMA table_info(_emdash_fields)
`.execute(db);
const columnNames = result.rows.map((r) => r.name);
expect(columnNames).toContain("translatable");
});
it("should have compound unique constraint on slug+locale", async () => {
// Insert same slug, different locale — should succeed
await sql`
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
VALUES ('id1', 'hello', 'en', 'id1', 'draft', 1, datetime('now'), datetime('now'))
`.execute(db);
await sql`
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
VALUES ('id2', 'hello', 'fr', 'id1', 'draft', 1, datetime('now'), datetime('now'))
`.execute(db);
// Same slug, same locale — should fail
await expect(
sql`
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
VALUES ('id3', 'hello', 'en', 'id3', 'draft', 1, datetime('now'), datetime('now'))
`.execute(db),
).rejects.toThrow();
});
it("should have locale and translation_group indexes", async () => {
const result = await sql<{ name: string }>`
PRAGMA index_list(ec_post)
`.execute(db);
const indexNames = result.rows.map((r) => r.name);
expect(indexNames).toContain("idx_ec_post_locale");
expect(indexNames).toContain("idx_ec_post_translation_group");
});
});
// ─── 2. ContentRepository — locale-aware CRUD ───────────────────
describe("ContentRepository — locale-aware CRUD", () => {
it("create() without locale defaults to 'en'", async () => {
const post = await repo.create(createPostFixture());
expect(post.locale).toBe("en");
});
it("create() with explicit locale stores it", async () => {
const post = await repo.create(createPostFixture({ locale: "fr", slug: "bonjour" }));
expect(post.locale).toBe("fr");
});
it("create() with translationOf links via translation_group", async () => {
const enPost = await repo.create(createPostFixture({ slug: "hello-world", locale: "en" }));
const frPost = await repo.create(
createPostFixture({
slug: "bonjour-monde",
locale: "fr",
translationOf: enPost.id,
data: { title: "Bonjour le Monde" },
}),
);
// Both should share the same translation_group
expect(frPost.translationGroup).toBe(enPost.translationGroup);
// The group should be the original item's id (since it was first)
expect(enPost.translationGroup).toBe(enPost.id);
});
it("create() with translationOf on a chained translation uses the root group", async () => {
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
const frPost = await repo.create(
createPostFixture({
slug: "bonjour",
locale: "fr",
translationOf: enPost.id,
data: { title: "Bonjour" },
}),
);
// Create a third translation linked to the French version
const dePost = await repo.create(
createPostFixture({
slug: "hallo",
locale: "de",
translationOf: frPost.id,
data: { title: "Hallo" },
}),
);
// All three should share the same translation_group
expect(dePost.translationGroup).toBe(enPost.id);
expect(frPost.translationGroup).toBe(enPost.id);
});
it("create() with translationOf pointing to non-existent ID throws", async () => {
await expect(
repo.create(
createPostFixture({
slug: "orphan",
locale: "fr",
translationOf: "NONEXISTENT_ID_12345678",
}),
),
).rejects.toThrow("Translation source content not found");
});
it("same slug different locales are allowed", async () => {
const en = await repo.create(createPostFixture({ slug: "about", locale: "en" }));
const fr = await repo.create(
createPostFixture({
slug: "about",
locale: "fr",
data: { title: "À propos" },
}),
);
expect(en.slug).toBe("about");
expect(fr.slug).toBe("about");
expect(en.id).not.toBe(fr.id);
});
it("same slug same locale is rejected", async () => {
await repo.create(createPostFixture({ slug: "unique-slug", locale: "en" }));
await expect(
repo.create(
createPostFixture({
slug: "unique-slug",
locale: "en",
data: { title: "Duplicate" },
}),
),
).rejects.toThrow();
});
// ── findBySlug ────────────────────────────────────────────────
it("findBySlug() without locale returns any match", async () => {
await repo.create(createPostFixture({ slug: "shared-slug", locale: "en" }));
await repo.create(
createPostFixture({
slug: "shared-slug",
locale: "fr",
data: { title: "Version FR" },
}),
);
const found = await repo.findBySlug("post", "shared-slug");
expect(found).not.toBeNull();
expect(found!.slug).toBe("shared-slug");
});
it("findBySlug() with locale filters to that locale", async () => {
await repo.create(createPostFixture({ slug: "about", locale: "en" }));
await repo.create(
createPostFixture({
slug: "about",
locale: "fr",
data: { title: "À propos" },
}),
);
const en = await repo.findBySlug("post", "about", "en");
expect(en).not.toBeNull();
expect(en!.locale).toBe("en");
const fr = await repo.findBySlug("post", "about", "fr");
expect(fr).not.toBeNull();
expect(fr!.locale).toBe("fr");
const de = await repo.findBySlug("post", "about", "de");
expect(de).toBeNull();
});
// ── findByIdOrSlug ────────────────────────────────────────────
it("findByIdOrSlug() — ID lookup ignores locale param", async () => {
const post = await repo.create(createPostFixture({ slug: "test-post", locale: "en" }));
// ID lookup should find it regardless of locale param
const found = await repo.findByIdOrSlug("post", post.id, "fr");
expect(found).not.toBeNull();
expect(found!.id).toBe(post.id);
expect(found!.locale).toBe("en");
});
it("findByIdOrSlug() — slug lookup respects locale", async () => {
const enPost = await repo.create(createPostFixture({ slug: "test", locale: "en" }));
const frPost = await repo.create(
createPostFixture({
slug: "test",
locale: "fr",
data: { title: "Test FR" },
}),
);
const foundEn = await repo.findByIdOrSlug("post", "test", "en");
expect(foundEn).not.toBeNull();
expect(foundEn!.id).toBe(enPost.id);
const foundFr = await repo.findByIdOrSlug("post", "test", "fr");
expect(foundFr).not.toBeNull();
expect(foundFr!.id).toBe(frPost.id);
});
// ── findMany ──────────────────────────────────────────────────
it("findMany() without locale returns all locales", async () => {
await repo.create(createPostFixture({ slug: "en-post", locale: "en" }));
await repo.create(
createPostFixture({
slug: "fr-post",
locale: "fr",
data: { title: "Post FR" },
}),
);
await repo.create(
createPostFixture({
slug: "de-post",
locale: "de",
data: { title: "Post DE" },
}),
);
const result = await repo.findMany("post");
expect(result.items).toHaveLength(3);
});
it("findMany() with locale filters to that locale", async () => {
await repo.create(createPostFixture({ slug: "en-post", locale: "en" }));
await repo.create(
createPostFixture({
slug: "fr-post",
locale: "fr",
data: { title: "Post FR" },
}),
);
await repo.create(
createPostFixture({
slug: "de-post",
locale: "de",
data: { title: "Post DE" },
}),
);
const frResult = await repo.findMany("post", {
where: { locale: "fr" },
});
expect(frResult.items).toHaveLength(1);
expect(frResult.items[0]!.locale).toBe("fr");
});
// ── count ─────────────────────────────────────────────────────
it("count() without locale counts all", async () => {
await repo.create(createPostFixture({ slug: "post-en", locale: "en" }));
await repo.create(
createPostFixture({
slug: "post-fr",
locale: "fr",
data: { title: "FR" },
}),
);
const total = await repo.count("post");
expect(total).toBe(2);
});
it("count() with locale counts only that locale", async () => {
await repo.create(createPostFixture({ slug: "post-en", locale: "en" }));
await repo.create(
createPostFixture({
slug: "post-fr",
locale: "fr",
data: { title: "FR" },
}),
);
const enCount = await repo.count("post", { locale: "en" });
expect(enCount).toBe(1);
const deCount = await repo.count("post", { locale: "de" });
expect(deCount).toBe(0);
});
// ── findTranslations ──────────────────────────────────────────
it("findTranslations() returns all locales for a translation group", async () => {
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
await repo.create(
createPostFixture({
slug: "bonjour",
locale: "fr",
translationOf: enPost.id,
data: { title: "Bonjour" },
}),
);
await repo.create(
createPostFixture({
slug: "hallo",
locale: "de",
translationOf: enPost.id,
data: { title: "Hallo" },
}),
);
const translations = await repo.findTranslations("post", enPost.translationGroup!);
expect(translations).toHaveLength(3);
const locales = translations
.map((t) => t.locale)
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
expect(locales).toEqual(["de", "en", "fr"]);
});
it("findTranslations() returns only non-deleted items", async () => {
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
const frPost = await repo.create(
createPostFixture({
slug: "bonjour",
locale: "fr",
translationOf: enPost.id,
data: { title: "Bonjour" },
}),
);
// Soft-delete the French translation
await repo.delete("post", frPost.id);
const translations = await repo.findTranslations("post", enPost.translationGroup!);
expect(translations).toHaveLength(1);
expect(translations[0]!.locale).toBe("en");
});
});
// ─── 3. FTS — locale-aware search ───────────────────────────────
describe("FTS — locale-aware search", () => {
let registry: SchemaRegistry;
let ftsManager: FTSManager;
beforeEach(async () => {
registry = new SchemaRegistry(db);
ftsManager = new FTSManager(db);
// Mark title as searchable and enable FTS
await registry.updateField("post", "title", { searchable: true });
await ftsManager.enableSearch("post");
});
it("search with locale filter returns only that locale's results", async () => {
// Create published posts in different locales
const enPost = await repo.create(
createPostFixture({
slug: "hello-world",
locale: "en",
status: "published",
data: { title: "Hello World" },
}),
);
const frPost = await repo.create(
createPostFixture({
slug: "bonjour-monde",
locale: "fr",
status: "published",
data: { title: "Bonjour le Monde" },
}),
);
// Search for "world" — English only
const enResults = await searchWithDb(db, "Hello", {
collections: ["post"],
locale: "en",
status: "published",
});
expect(enResults.items.length).toBeGreaterThanOrEqual(1);
expect(enResults.items.every((r) => r.locale === "en")).toBe(true);
expect(enResults.items.some((r) => r.id === enPost.id)).toBe(true);
// Search for "Bonjour" — French only
const frResults = await searchWithDb(db, "Bonjour", {
collections: ["post"],
locale: "fr",
status: "published",
});
expect(frResults.items.length).toBeGreaterThanOrEqual(1);
expect(frResults.items.every((r) => r.locale === "fr")).toBe(true);
expect(frResults.items.some((r) => r.id === frPost.id)).toBe(true);
});
it("search without locale returns results from all locales", async () => {
await repo.create(
createPostFixture({
slug: "universal-en",
locale: "en",
status: "published",
data: { title: "Universal Content" },
}),
);
await repo.create(
createPostFixture({
slug: "universal-fr",
locale: "fr",
status: "published",
data: { title: "Universal Contenu" },
}),
);
const results = await searchWithDb(db, "Universal", {
collections: ["post"],
status: "published",
});
expect(results.items).toHaveLength(2);
const locales = results.items.map((r) => r.locale).toSorted();
expect(locales).toEqual(["en", "fr"]);
});
it("FTS index includes locale column", async () => {
// Verify the FTS table has the locale column by checking structure
const exists = await ftsManager.ftsTableExists("post");
expect(exists).toBe(true);
// Create a post and verify it appears in FTS results with locale
await repo.create(
createPostFixture({
slug: "fts-test",
locale: "ja",
status: "published",
data: { title: "FTS Locale Test" },
}),
);
const results = await searchWithDb(db, "FTS Locale", {
collections: ["post"],
locale: "ja",
status: "published",
});
expect(results.items).toHaveLength(1);
expect(results.items[0]!.locale).toBe("ja");
});
it("rebuilt index preserves locale-aware search", async () => {
// Create content before rebuild
await repo.create(
createPostFixture({
slug: "pre-rebuild-en",
locale: "en",
status: "published",
data: { title: "Rebuild Test English" },
}),
);
await repo.create(
createPostFixture({
slug: "pre-rebuild-fr",
locale: "fr",
status: "published",
data: { title: "Rebuild Test French" },
}),
);
// Rebuild the index
await ftsManager.rebuildIndex("post", ["title"]);
// Verify locale-aware search still works
const enResults = await searchWithDb(db, "Rebuild", {
collections: ["post"],
locale: "en",
status: "published",
});
expect(enResults.items).toHaveLength(1);
expect(enResults.items[0]!.locale).toBe("en");
});
});
// ─── 4. Seed — locale-aware content ─────────────────────────────
describe("Seed — locale-aware content", () => {
it("applySeed() creates content with locale and translationOf", async () => {
const seed: SeedFile = {
version: "1",
content: {
post: [
{
id: "welcome",
slug: "welcome",
locale: "en",
status: "published",
data: { title: "Welcome" },
},
{
id: "welcome-fr",
slug: "bienvenue",
locale: "fr",
translationOf: "welcome",
status: "draft",
data: { title: "Bienvenue" },
},
{
id: "welcome-de",
slug: "willkommen",
locale: "de",
translationOf: "welcome",
status: "published",
data: { title: "Willkommen" },
},
],
},
};
const result = await applySeed(db, seed, { includeContent: true });
expect(result.content.created).toBe(3);
expect(result.content.skipped).toBe(0);
// Verify the entries exist with correct locales
const seedRepo = new ContentRepository(db);
const enPost = await seedRepo.findBySlug("post", "welcome", "en");
const frPost = await seedRepo.findBySlug("post", "bienvenue", "fr");
const dePost = await seedRepo.findBySlug("post", "willkommen", "de");
expect(enPost).not.toBeNull();
expect(frPost).not.toBeNull();
expect(dePost).not.toBeNull();
expect(enPost!.locale).toBe("en");
expect(frPost!.locale).toBe("fr");
expect(dePost!.locale).toBe("de");
// All should share the same translation_group
expect(frPost!.translationGroup).toBe(enPost!.translationGroup);
expect(dePost!.translationGroup).toBe(enPost!.translationGroup);
});
it("applySeed() without locale falls back to default", async () => {
const seed: SeedFile = {
version: "1",
content: {
post: [
{
id: "plain",
slug: "plain-post",
data: { title: "No Locale" },
},
],
},
};
const result = await applySeed(db, seed, { includeContent: true });
expect(result.content.created).toBe(1);
const plainRepo = new ContentRepository(db);
const post = await plainRepo.findBySlug("post", "plain-post");
expect(post).not.toBeNull();
expect(post!.locale).toBe("en"); // default
expect(post!.translationGroup).toBe(post!.id); // self-reference
});
it("applySeed() skips existing entries with locale-aware lookup", async () => {
// Pre-create an entry
const skipRepo = new ContentRepository(db);
await skipRepo.create(createPostFixture({ slug: "existing", locale: "fr" }));
const seed: SeedFile = {
version: "1",
content: {
post: [
{
id: "existing",
slug: "existing",
locale: "fr",
data: { title: "Should Skip" },
},
],
},
};
const result = await applySeed(db, seed, { includeContent: true });
expect(result.content.skipped).toBe(1);
expect(result.content.created).toBe(0);
});
it("applySeed() rejects missing translationOf via validation", async () => {
const seed: SeedFile = {
version: "1",
content: {
post: [
{
id: "orphan-fr",
slug: "orphelin",
locale: "fr",
translationOf: "nonexistent",
data: { title: "Orphan" },
},
],
},
};
// Validation catches the bad reference before applySeed runs
await expect(applySeed(db, seed, { includeContent: true })).rejects.toThrow(
'references "nonexistent" which is not in this collection',
);
});
});
// ─── 5. Seed validation — i18n fields ───────────────────────────
describe("Seed validation — i18n fields", () => {
it("validates translationOf requires locale", () => {
const seed = {
version: "1",
content: {
posts: [
{ id: "en", slug: "hello", data: { title: "Hello" } },
{
id: "fr",
slug: "bonjour",
translationOf: "en",
data: { title: "Bonjour" },
},
],
},
};
const result = validateSeed(seed);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("locale is required when translationOf"))).toBe(
true,
);
});
it("validates translationOf references exist", () => {
const seed = {
version: "1",
content: {
posts: [
{
id: "fr",
slug: "bonjour",
locale: "fr",
translationOf: "nonexistent",
data: { title: "Bonjour" },
},
],
},
};
const result = validateSeed(seed);
expect(result.valid).toBe(false);
expect(
result.errors.some((e) => e.includes('references "nonexistent" which is not in')),
).toBe(true);
});
it("valid seed with i18n fields passes validation", () => {
const seed = {
version: "1",
content: {
posts: [
{ id: "en", slug: "hello", locale: "en", data: { title: "Hello" } },
{
id: "fr",
slug: "bonjour",
locale: "fr",
translationOf: "en",
data: { title: "Bonjour" },
},
],
},
};
const result = validateSeed(seed);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
// ─── 6. Non-i18n regression ─────────────────────────────────────
describe("Non-i18n regression", () => {
it("content created without locale has locale 'en'", async () => {
const post = await repo.create({
type: "post",
slug: "no-locale",
data: { title: "No Locale Specified" },
});
expect(post.locale).toBe("en");
});
it("findMany without locale param returns all results", async () => {
await repo.create(createPostFixture({ slug: "post-1" }));
await repo.create(createPostFixture({ slug: "post-2" }));
const result = await repo.findMany("post");
expect(result.items).toHaveLength(2);
});
it("findBySlug works without locale param", async () => {
const created = await repo.create(createPostFixture({ slug: "find-me" }));
const found = await repo.findBySlug("post", "find-me");
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
});
it("findByIdOrSlug works without locale param", async () => {
const created = await repo.create(createPostFixture({ slug: "lookup-test" }));
// By slug
const bySlug = await repo.findByIdOrSlug("post", "lookup-test");
expect(bySlug).not.toBeNull();
expect(bySlug!.id).toBe(created.id);
// By ID
const byId = await repo.findByIdOrSlug("post", created.id);
expect(byId).not.toBeNull();
expect(byId!.id).toBe(created.id);
});
it("slug uniqueness is still enforced within the same locale", async () => {
await repo.create(createPostFixture({ slug: "dupe-test" }));
// Same slug, same default locale — should fail
await expect(repo.create(createPostFixture({ slug: "dupe-test" }))).rejects.toThrow();
});
it("count works without locale param", async () => {
await repo.create(createPostFixture({ slug: "count-1" }));
await repo.create(createPostFixture({ slug: "count-2" }));
const count = await repo.count("post");
expect(count).toBe(2);
});
it("translation_group is auto-set to item id when no translationOf", async () => {
const post = await repo.create(createPostFixture({ slug: "standalone" }));
expect(post.translationGroup).toBe(post.id);
});
it("existing CRUD operations are unaffected by i18n columns", async () => {
// Create
const post = await repo.create(createPostFixture({ slug: "crud-test", status: "draft" }));
expect(post.status).toBe("draft");
// Update
const updated = await repo.update("post", post.id, {
data: { title: "Updated Title" },
});
expect(updated.data.title).toBe("Updated Title");
expect(updated.locale).toBe("en"); // locale unchanged
// Delete (soft)
const deleted = await repo.delete("post", post.id);
expect(deleted).toBe(true);
// Should not be found
const notFound = await repo.findById("post", post.id);
expect(notFound).toBeNull();
// Restore
const restored = await repo.restore("post", post.id);
expect(restored).toBe(true);
const found = await repo.findById("post", post.id);
expect(found).not.toBeNull();
expect(found!.locale).toBe("en");
});
});
});