b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
793 lines
26 KiB
TypeScript
793 lines
26 KiB
TypeScript
import { sql, type Kysely } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import {
|
|
handleContentCreate,
|
|
handleContentDuplicate,
|
|
handleContentGet,
|
|
handleContentList,
|
|
handleContentPublish,
|
|
handleContentUpdate,
|
|
} from "../../../src/api/index.js";
|
|
import { BylineRepository } from "../../../src/database/repositories/byline.js";
|
|
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
|
import { UserRepository } from "../../../src/database/repositories/user.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { setI18nConfig } from "../../../src/i18n/config.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
describe("Content Handlers — auto-slug generation", () => {
|
|
let db: Kysely<Database>;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
// Add a "name" field to the page collection so we can test name-based slug generation
|
|
const registry = new SchemaRegistry(db);
|
|
await registry.createField("page", { slug: "name", label: "Name", type: "string" });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
describe("handleContentCreate", () => {
|
|
it("should auto-generate slug from title when slug is omitted", async () => {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("hello-world");
|
|
});
|
|
|
|
it("should auto-generate slug from name when title is absent", async () => {
|
|
const result = await handleContentCreate(db, "page", {
|
|
data: { name: "My Widget" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("my-widget");
|
|
});
|
|
|
|
it("should prefer title over name for slug generation", async () => {
|
|
const result = await handleContentCreate(db, "page", {
|
|
data: { title: "From Title", name: "From Name" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("from-title");
|
|
});
|
|
|
|
it("should respect explicit slug and not auto-generate", async () => {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
slug: "custom-slug",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("custom-slug");
|
|
});
|
|
|
|
it("should handle slug collisions by appending numeric suffix", async () => {
|
|
// Create first item with the slug
|
|
await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
|
|
// Create second item with same title — should get unique slug
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("hello-world-1");
|
|
});
|
|
|
|
it("should increment suffix on repeated collisions", async () => {
|
|
await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello World" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("hello-world-2");
|
|
});
|
|
|
|
it("should leave slug null when no title or name is present", async () => {
|
|
// `data: {}` — no title, no name. Slug source isn't there, so the
|
|
// auto-generator has nothing to work with.
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: {},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBeNull();
|
|
});
|
|
|
|
it("should leave slug null when title is empty string", async () => {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBeNull();
|
|
});
|
|
|
|
it("should handle unicode titles", async () => {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Café Naïve" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.slug).toBe("cafe-naive");
|
|
});
|
|
|
|
it("should allow same auto-slug in different collections", async () => {
|
|
const postResult = await handleContentCreate(db, "post", {
|
|
data: { title: "About" },
|
|
});
|
|
const pageResult = await handleContentCreate(db, "page", {
|
|
data: { title: "About" },
|
|
});
|
|
|
|
expect(postResult.success).toBe(true);
|
|
expect(pageResult.success).toBe(true);
|
|
expect(postResult.data?.item.slug).toBe("about");
|
|
expect(pageResult.data?.item.slug).toBe("about");
|
|
});
|
|
|
|
it("preserves publishedAt and createdAt when provided — content migration use case", async () => {
|
|
const originalCreated = "2019-03-15T10:30:00.000Z";
|
|
const originalPublished = "2019-03-16T09:00:00.000Z";
|
|
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Migrated Post" },
|
|
createdAt: originalCreated,
|
|
publishedAt: originalPublished,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.createdAt).toBe(originalCreated);
|
|
expect(result.data?.item.publishedAt).toBe(originalPublished);
|
|
});
|
|
|
|
// When the caller omits `locale`, the handler must defer to the
|
|
// configured site defaultLocale rather than falling back to the
|
|
// repo's hard-coded "en". Otherwise non-English default-locale
|
|
// sites silently create entries in a locale the editor never chose.
|
|
it("defaults to configured i18n defaultLocale when body.locale is omitted", async () => {
|
|
setI18nConfig({ defaultLocale: "es", locales: ["es", "en"] });
|
|
try {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "Hola" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.locale).toBe("es");
|
|
} finally {
|
|
setI18nConfig(null);
|
|
}
|
|
});
|
|
|
|
it("falls back to 'en' when no i18n config is set", async () => {
|
|
setI18nConfig(null);
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: "No i18n" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.item.locale).toBe("en");
|
|
});
|
|
});
|
|
|
|
describe("handleContentDuplicate", () => {
|
|
it("should generate slug from duplicated title", async () => {
|
|
const original = await handleContentCreate(db, "post", {
|
|
data: { title: "My Post" },
|
|
slug: "my-post",
|
|
});
|
|
|
|
const result = await handleContentDuplicate(db, "post", original.data!.item.id);
|
|
|
|
expect(result.success).toBe(true);
|
|
// Title becomes "My Post (Copy)", slug should be generated from it
|
|
expect(result.data?.item.slug).toBe("my-post-copy");
|
|
});
|
|
|
|
it("should handle duplicate slug collision from copy", async () => {
|
|
const original = await handleContentCreate(db, "post", {
|
|
data: { title: "My Post" },
|
|
slug: "my-post",
|
|
});
|
|
|
|
// First duplicate
|
|
const dup1 = await handleContentDuplicate(db, "post", original.data!.item.id);
|
|
expect(dup1.data?.item.slug).toBe("my-post-copy");
|
|
|
|
// Second duplicate — "My Post (Copy)" title slugifies to "my-post-copy"
|
|
// which now collides with the first duplicate
|
|
const dup2 = await handleContentDuplicate(db, "post", original.data!.item.id);
|
|
expect(dup2.success).toBe(true);
|
|
expect(dup2.data?.item.slug).toBe("my-post-copy-1");
|
|
});
|
|
});
|
|
|
|
describe("handleContentUpdate — locale-aware slug resolution", () => {
|
|
it("updates the row matching body.locale when the identifier is a shared slug", async () => {
|
|
const slug = "shared-locale-update";
|
|
const en = await handleContentCreate(db, "post", {
|
|
data: { title: "English" },
|
|
slug,
|
|
locale: "en",
|
|
});
|
|
const fr = await handleContentCreate(db, "post", {
|
|
data: { title: "French" },
|
|
slug,
|
|
locale: "fr",
|
|
});
|
|
expect(en.success).toBe(true);
|
|
expect(fr.success).toBe(true);
|
|
|
|
const currentFr = await handleContentGet(db, "post", slug, "fr");
|
|
expect(currentFr.success).toBe(true);
|
|
|
|
const updated = await handleContentUpdate(db, "post", slug, {
|
|
data: { title: "French Updated" },
|
|
locale: "fr",
|
|
_rev: currentFr.data!._rev,
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(updated.data?.item.id).toBe(fr.data?.item.id);
|
|
expect(updated.data?.item.locale).toBe("fr");
|
|
expect(updated.data?.item.data.title).toBe("French Updated");
|
|
|
|
const fetchedEn = await handleContentGet(db, "post", slug, "en");
|
|
const fetchedFr = await handleContentGet(db, "post", slug, "fr");
|
|
expect(fetchedEn.data?.item.data.title).toBe("English");
|
|
expect(fetchedFr.data?.item.data.title).toBe("French Updated");
|
|
});
|
|
});
|
|
|
|
describe("byline hydration and assignment", () => {
|
|
it("should assign and return bylines on create", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const byline = await bylineRepo.create({
|
|
slug: "author-one",
|
|
displayName: "Author One",
|
|
});
|
|
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Bylined" },
|
|
bylines: [{ bylineId: byline.id, roleLabel: "Writer" }],
|
|
});
|
|
|
|
expect(created.success).toBe(true);
|
|
expect(created.data?.item.primaryBylineId).toBe(byline.id);
|
|
expect(created.data?.item.byline?.id).toBe(byline.id);
|
|
expect(created.data?.item.bylines).toHaveLength(1);
|
|
expect(created.data?.item.bylines?.[0]?.roleLabel).toBe("Writer");
|
|
});
|
|
|
|
it("should return bylines on get and list", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const first = await bylineRepo.create({ slug: "first", displayName: "First" });
|
|
const second = await bylineRepo.create({ slug: "second", displayName: "Second" });
|
|
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Order Test" },
|
|
bylines: [{ bylineId: second.id }, { bylineId: first.id }],
|
|
});
|
|
expect(created.success).toBe(true);
|
|
const contentId = created.data!.item.id;
|
|
|
|
const fetched = await handleContentGet(db, "post", contentId);
|
|
expect(fetched.success).toBe(true);
|
|
expect(fetched.data?.item.bylines?.[0]?.byline.id).toBe(second.id);
|
|
expect(fetched.data?.item.bylines?.[1]?.byline.id).toBe(first.id);
|
|
expect(fetched.data?.item.byline?.id).toBe(second.id);
|
|
|
|
const listed = await handleContentList(db, "post", {});
|
|
expect(listed.success).toBe(true);
|
|
const listedItem = listed.data?.items.find((item) => item.id === contentId);
|
|
expect(listedItem?.byline?.id).toBe(second.id);
|
|
expect(listedItem?.bylines?.[0]?.byline.id).toBe(second.id);
|
|
});
|
|
|
|
it("should update byline ordering on update", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const first = await bylineRepo.create({ slug: "first-upd", displayName: "First" });
|
|
const second = await bylineRepo.create({ slug: "second-upd", displayName: "Second" });
|
|
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Update Bylines" },
|
|
bylines: [{ bylineId: first.id }, { bylineId: second.id }],
|
|
});
|
|
expect(created.success).toBe(true);
|
|
|
|
const updated = await handleContentUpdate(db, "post", created.data!.item.id, {
|
|
bylines: [{ bylineId: second.id }, { bylineId: first.id }],
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(updated.data?.item.primaryBylineId).toBe(second.id);
|
|
expect(updated.data?.item.bylines?.[0]?.byline.id).toBe(second.id);
|
|
expect(updated.data?.item.bylines?.[1]?.byline.id).toBe(first.id);
|
|
});
|
|
|
|
it("should copy bylines when duplicating", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const byline = await bylineRepo.create({
|
|
slug: "dup-author",
|
|
displayName: "Dup Author",
|
|
});
|
|
|
|
const original = await handleContentCreate(db, "post", {
|
|
data: { title: "Duplicate With Bylines" },
|
|
bylines: [{ bylineId: byline.id }],
|
|
});
|
|
expect(original.success).toBe(true);
|
|
|
|
const duplicated = await handleContentDuplicate(db, "post", original.data!.item.id);
|
|
expect(duplicated.success).toBe(true);
|
|
expect(duplicated.data?.item.byline?.id).toBe(byline.id);
|
|
expect(duplicated.data?.item.bylines).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("byline i18n (migration 040)", () => {
|
|
it("sets primaryBylineId to the credited byline's translation_group, not the wire row id", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
const fr = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
// Editor credits the fr row on a fr entry. The wire bylineId is
|
|
// fr.id but the server should store the translation_group (which
|
|
// equals anchor.id for the anchor sibling). The in-memory
|
|
// `primaryBylineId` on the response must match what's now in the
|
|
// DB column.
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
bylines: [{ bylineId: fr.id }],
|
|
});
|
|
|
|
expect(created.success).toBe(true);
|
|
expect(created.data?.item.primaryBylineId).toBe(anchor.id);
|
|
expect(created.data?.item.primaryBylineId).toBe(anchor.translationGroup);
|
|
// fr post hydrates with the fr sibling.
|
|
expect(created.data?.item.byline?.id).toBe(fr.id);
|
|
expect(created.data?.item.byline?.locale).toBe("fr");
|
|
});
|
|
|
|
it("hydrates the locale-matching sibling on a translated entry", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
// Credit using the anchor (en) wire id on an EN entry, then
|
|
// translate the entry. The fr-locale entry should hydrate the fr
|
|
// byline sibling, not the en one.
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
bylines: [{ bylineId: anchor.id }],
|
|
});
|
|
expect(enEntry.success).toBe(true);
|
|
expect(enEntry.data?.item.byline?.locale).toBe("en");
|
|
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
translationOf: enEntry.data!.item.id,
|
|
});
|
|
expect(frEntry.success).toBe(true);
|
|
expect(frEntry.data?.item.byline?.displayName).toBe("Jeanne");
|
|
expect(frEntry.data?.item.byline?.locale).toBe("fr");
|
|
});
|
|
|
|
it("hydrates strict per locale — credit absent when no sibling exists at the entry's locale", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const anchor = await bylineRepo.create({
|
|
slug: "marco",
|
|
displayName: "Marco",
|
|
locale: "en",
|
|
});
|
|
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
bylines: [{ bylineId: anchor.id }],
|
|
});
|
|
expect(enEntry.success).toBe(true);
|
|
|
|
// Translate to fr — no fr sibling exists for marco. The credit is
|
|
// preserved at the DB level (`copyContentBylines` clones the
|
|
// junction row pointing at marco's translation_group), but
|
|
// rendering on the fr entry returns nothing because the byline
|
|
// has no fr sibling.
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
translationOf: enEntry.data!.item.id,
|
|
});
|
|
expect(frEntry.success).toBe(true);
|
|
expect(frEntry.data?.item.bylines).toEqual([]);
|
|
expect(frEntry.data?.item.byline).toBeNull();
|
|
// But the DB-level pointer is preserved — the credit will surface
|
|
// the moment an fr sibling of marco is created.
|
|
expect(frEntry.data?.item.primaryBylineId).toBe(anchor.id);
|
|
});
|
|
|
|
it("copyContentBylines runs on translationOf when body.bylines is omitted", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
bylines: [{ bylineId: anchor.id }],
|
|
});
|
|
expect(enEntry.success).toBe(true);
|
|
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
translationOf: enEntry.data!.item.id,
|
|
// No `bylines` field — copy should run.
|
|
});
|
|
expect(frEntry.success).toBe(true);
|
|
expect(frEntry.data?.item.bylines).toHaveLength(1);
|
|
expect(frEntry.data?.item.byline?.locale).toBe("fr");
|
|
});
|
|
|
|
it("respects explicit body.bylines on translationOf and skips copying", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const a = await bylineRepo.create({ slug: "a", displayName: "A", locale: "en" });
|
|
const b = await bylineRepo.create({ slug: "b", displayName: "B", locale: "en" });
|
|
// fr sibling of `b`. The fr entry will be credited with this row.
|
|
const bFr = await bylineRepo.create({
|
|
slug: "b",
|
|
displayName: "B-fr",
|
|
locale: "fr",
|
|
translationOf: b.id,
|
|
});
|
|
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
bylines: [{ bylineId: a.id }],
|
|
});
|
|
expect(enEntry.success).toBe(true);
|
|
|
|
// Explicit different byline on the translation — should NOT
|
|
// inherit from source. The fr entry should credit `b`'s fr
|
|
// sibling, not `a`.
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
translationOf: enEntry.data!.item.id,
|
|
bylines: [{ bylineId: bFr.id }],
|
|
});
|
|
expect(frEntry.success).toBe(true);
|
|
expect(frEntry.data?.item.bylines).toHaveLength(1);
|
|
expect(frEntry.data?.item.byline?.id).toBe(bFr.id);
|
|
});
|
|
|
|
it("does not fall back to author-linked byline when explicit credits exist at another locale", async () => {
|
|
// Bug 1 regression: strict per-locale hydration returns [] when a
|
|
// credit's translation_group has no sibling at the entry's
|
|
// locale, but the old code treated that as "no explicit
|
|
// credits" and inferred the author byline. The editor's intent
|
|
// was explicit ("credit Marco, not the article's author") —
|
|
// silently overriding with an inferred byline is wrong.
|
|
const userRepo = new UserRepository(db);
|
|
const author = await userRepo.create({
|
|
email: "fallback-test@example.com",
|
|
displayName: "Author Account",
|
|
role: "editor",
|
|
});
|
|
const bylineRepo = new BylineRepository(db);
|
|
// Author has a fr byline. If we incorrectly fell back, this
|
|
// would surface on a fr entry that has an unresolved en-only
|
|
// credit.
|
|
await bylineRepo.create({
|
|
slug: "author-fr",
|
|
displayName: "Auteur",
|
|
locale: "fr",
|
|
userId: author.id,
|
|
});
|
|
|
|
const marco = await bylineRepo.create({
|
|
slug: "marco",
|
|
displayName: "Marco",
|
|
locale: "en",
|
|
});
|
|
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
authorId: author.id,
|
|
bylines: [{ bylineId: marco.id }],
|
|
});
|
|
expect(enEntry.success).toBe(true);
|
|
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
authorId: author.id,
|
|
translationOf: enEntry.data!.item.id,
|
|
});
|
|
expect(frEntry.success).toBe(true);
|
|
// The explicit credit was copied to the fr entry (via
|
|
// `copyContentBylines`). It doesn't render at fr because marco
|
|
// has no fr sibling. Author inference must NOT step in — the
|
|
// editor explicitly named marco, not the author.
|
|
expect(frEntry.data?.item.bylines).toEqual([]);
|
|
expect(frEntry.data?.item.byline).toBeNull();
|
|
});
|
|
|
|
it("inferred (author-linked) byline still works when the entry has no explicit credits at all", async () => {
|
|
// Counterpoint to the previous test: when no junction rows
|
|
// exist, author inference is the right thing to do — Phase 4
|
|
// hydration must preserve this fallback for entries that were
|
|
// never explicitly credited.
|
|
const userRepo = new UserRepository(db);
|
|
const author = await userRepo.create({
|
|
email: "implicit-author@example.com",
|
|
displayName: "Implicit",
|
|
role: "editor",
|
|
});
|
|
const bylineRepo = new BylineRepository(db);
|
|
await bylineRepo.create({
|
|
slug: "implicit",
|
|
displayName: "Implicit EN",
|
|
locale: "en",
|
|
userId: author.id,
|
|
});
|
|
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
authorId: author.id,
|
|
// No bylines field — author inference path.
|
|
});
|
|
expect(created.success).toBe(true);
|
|
expect(created.data?.item.bylines).toHaveLength(1);
|
|
expect(created.data?.item.bylines?.[0]?.source).toBe("inferred");
|
|
expect(created.data?.item.byline?.displayName).toBe("Implicit EN");
|
|
});
|
|
|
|
it("hydrateBylinesMany resolves each entry against its own locale", async () => {
|
|
const bylineRepo = new BylineRepository(db);
|
|
const anchor = await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jane Doe",
|
|
locale: "en",
|
|
});
|
|
await bylineRepo.create({
|
|
slug: "jane",
|
|
displayName: "Jeanne",
|
|
locale: "fr",
|
|
translationOf: anchor.id,
|
|
});
|
|
|
|
// Two entries at different locales, both credited with the same
|
|
// translation_group (via the anchor wire id). A list endpoint
|
|
// returning both at once should hydrate each one against its own
|
|
// locale.
|
|
const enEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
locale: "en",
|
|
bylines: [{ bylineId: anchor.id }],
|
|
});
|
|
const frEntry = await handleContentCreate(db, "post", {
|
|
data: { title: "Bonjour" },
|
|
locale: "fr",
|
|
translationOf: enEntry.data!.item.id,
|
|
});
|
|
expect(enEntry.success && frEntry.success).toBe(true);
|
|
|
|
const listed = await handleContentList(db, "post", {});
|
|
expect(listed.success).toBe(true);
|
|
const enItem = listed.data?.items.find((i) => i.id === enEntry.data!.item.id);
|
|
const frItem = listed.data?.items.find((i) => i.id === frEntry.data!.item.id);
|
|
expect(enItem?.byline?.locale).toBe("en");
|
|
expect(frItem?.byline?.locale).toBe("fr");
|
|
});
|
|
});
|
|
|
|
describe("handleContentUpdate — publishedAt override", () => {
|
|
it("persists publishedAt when provided", async () => {
|
|
const created = await handleContentCreate(db, "post", { data: { title: "Hi" } });
|
|
expect(created.success).toBe(true);
|
|
|
|
const newPublishedAt = "2019-03-16T09:00:00.000Z";
|
|
const updated = await handleContentUpdate(db, "post", created.data!.item.id, {
|
|
publishedAt: newPublishedAt,
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(updated.data?.item.publishedAt).toBe(newPublishedAt);
|
|
});
|
|
|
|
it("leaves createdAt untouched on update", async () => {
|
|
const originalCreated = "2019-03-15T10:30:00.000Z";
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Hi" },
|
|
createdAt: originalCreated,
|
|
});
|
|
expect(created.success).toBe(true);
|
|
|
|
const updated = await handleContentUpdate(db, "post", created.data!.item.id, {
|
|
data: { title: "Edited" },
|
|
publishedAt: "2020-01-01T00:00:00.000Z",
|
|
});
|
|
|
|
expect(updated.success).toBe(true);
|
|
expect(updated.data?.item.createdAt).toBe(originalCreated);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Content Handlers — list total", () => {
|
|
let db: Kysely<Database>;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
// Seed enough items that limit-based pagination kicks in and we can
|
|
// assert total > items.length.
|
|
for (let i = 0; i < 8; i++) {
|
|
const result = await handleContentCreate(db, "post", {
|
|
data: { title: `Post ${i}` },
|
|
});
|
|
if (!result.success) throw new Error("seed failed");
|
|
}
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("returns total independent of limit", async () => {
|
|
const result = await handleContentList(db, "post", { limit: 2 });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.items).toHaveLength(2);
|
|
expect(result.data?.total).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe("Content Handlers — slug-change auto-redirect on publish", () => {
|
|
let db: Kysely<Database>;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
/**
|
|
* Stage a slug change the way the runtime does for revision-supporting
|
|
* collections: write `_slug` into a draft revision and point the entry's
|
|
* `draft_revision_id` at it, leaving the live `slug` column untouched.
|
|
*/
|
|
async function stageDraftSlugChange(
|
|
collection: string,
|
|
entryId: string,
|
|
data: Record<string, unknown>,
|
|
newSlug: string,
|
|
): Promise<void> {
|
|
const revisionRepo = new RevisionRepository(db);
|
|
const revision = await revisionRepo.create({
|
|
collection,
|
|
entryId,
|
|
data: { ...data, _slug: newSlug },
|
|
});
|
|
await sql`
|
|
UPDATE ${sql.ref(`ec_${collection}`)}
|
|
SET draft_revision_id = ${revision.id}
|
|
WHERE id = ${entryId}
|
|
`.execute(db);
|
|
}
|
|
|
|
it("creates a 301 from the old URL when publishing a staged slug change", async () => {
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Hello" },
|
|
slug: "hello",
|
|
status: "published",
|
|
});
|
|
expect(created.success).toBe(true);
|
|
const id = created.data!.item.id;
|
|
|
|
await stageDraftSlugChange("post", id, { title: "Hello" }, "hello-world");
|
|
|
|
const published = await handleContentPublish(db, "post", id);
|
|
expect(published.success).toBe(true);
|
|
expect(published.data?.item.slug).toBe("hello-world");
|
|
|
|
const redirects = await db.selectFrom("_emdash_redirects").selectAll().execute();
|
|
expect(redirects).toHaveLength(1);
|
|
expect(redirects[0]).toMatchObject({
|
|
source: "/post/hello",
|
|
destination: "/post/hello-world",
|
|
type: 301,
|
|
auto: 1,
|
|
});
|
|
});
|
|
|
|
it("does not create a redirect on first publish (draft URL was never live)", async () => {
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Fresh" },
|
|
slug: "fresh-draft",
|
|
status: "draft",
|
|
});
|
|
expect(created.success).toBe(true);
|
|
const id = created.data!.item.id;
|
|
|
|
await stageDraftSlugChange("post", id, { title: "Fresh" }, "fresh-final");
|
|
|
|
const published = await handleContentPublish(db, "post", id);
|
|
expect(published.success).toBe(true);
|
|
expect(published.data?.item.slug).toBe("fresh-final");
|
|
|
|
const redirects = await db.selectFrom("_emdash_redirects").selectAll().execute();
|
|
expect(redirects).toHaveLength(0);
|
|
});
|
|
|
|
it("does not create a redirect when republishing without a slug change", async () => {
|
|
const created = await handleContentCreate(db, "post", {
|
|
data: { title: "Stable" },
|
|
slug: "stable",
|
|
status: "published",
|
|
});
|
|
expect(created.success).toBe(true);
|
|
const id = created.data!.item.id;
|
|
|
|
await stageDraftSlugChange("post", id, { title: "Stable (edited)" }, "stable");
|
|
|
|
const published = await handleContentPublish(db, "post", id);
|
|
expect(published.success).toBe(true);
|
|
|
|
const redirects = await db.selectFrom("_emdash_redirects").selectAll().execute();
|
|
expect(redirects).toHaveLength(0);
|
|
});
|
|
});
|