Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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);
});
});