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

548 lines
15 KiB
TypeScript

/**
* customFields write surface on the admin bylines POST (create) route
* (Phase 6 of Discussion #1174).
*/
import { Role } from "@emdash-cms/auth";
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GET as getById } from "../../../src/astro/routes/api/admin/bylines/[id]/index.js";
import { POST as createByline } from "../../../src/astro/routes/api/admin/bylines/index.js";
import { resetBylineFieldDefsCacheForTests } from "../../../src/bylines/field-defs-cache.js";
import { BylineRepository } from "../../../src/database/repositories/byline.js";
import type { Database } from "../../../src/database/types.js";
import { BylineSchemaRegistry } from "../../../src/schema/byline-registry.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface BuildOpts {
db: Kysely<Database>;
request: Request;
params?: { id: string };
user: { id: string; role: (typeof Role)[keyof typeof Role] } | null;
}
function buildContext(opts: BuildOpts): APIContext {
return {
params: opts.params ?? {},
url: new URL(opts.request.url),
request: opts.request,
locals: {
emdash: { db: opts.db, config: {} },
user: opts.user,
},
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
} as unknown as APIContext;
}
function postReq(body: unknown): Request {
return new Request("http://localhost/_emdash/api/admin/bylines", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-EmDash-Request": "1",
},
body: JSON.stringify(body),
});
}
function getReq(id: string): Request {
return new Request(`http://localhost/_emdash/api/admin/bylines/${id}`, {
method: "GET",
headers: { "X-EmDash-Request": "1" },
});
}
const adminUser = { id: "admin-1", role: Role.ADMIN };
const baseCreate = {
slug: "jane-doe",
displayName: "Jane Doe",
isGuest: true,
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("POST /admin/bylines — customFields write surface", () => {
let db: Kysely<Database>;
beforeEach(async () => {
// The field-defs cache lives on globalThis (see
// `field-defs-cache.ts`); across tests in the same Vitest worker
// it would otherwise hold stale field IDs from a previous test's
// in-memory DB, producing spurious FK failures.
resetBylineFieldDefsCacheForTests();
db = await setupTestDatabase();
const registry = new BylineSchemaRegistry(db);
await registry.createField({
slug: "job_title",
label: "Job title",
type: "string",
translatable: true,
});
await registry.createField({
slug: "twitter_handle",
label: "Twitter",
type: "url",
translatable: false,
});
await registry.createField({
slug: "tier",
label: "Tier",
type: "select",
translatable: true,
validation: { options: ["bronze", "silver", "gold"] },
});
});
afterEach(async () => {
await teardownTestDatabase(db);
});
// ===========================================
// Round-trip
// ===========================================
it("POST writes customFields, GET reads them back", async () => {
const createRes = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: {
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
tier: "gold",
},
}),
user: adminUser,
}),
);
expect(createRes.status).toBe(201);
const createJson = (await createRes.json()) as {
data: { id: string; customFields?: Record<string, unknown> };
};
expect(createJson.data.customFields).toMatchObject({
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
tier: "gold",
});
const getRes = await getById(
buildContext({
db,
request: getReq(createJson.data.id),
params: { id: createJson.data.id },
user: adminUser,
}),
);
expect(getRes.status).toBe(200);
const getJson = (await getRes.json()) as {
data: { customFields?: Record<string, unknown> };
};
expect(getJson.data.customFields).toMatchObject({
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
tier: "gold",
});
});
it("create without customFields still works (back-compat)", async () => {
const res = await createByline(
buildContext({
db,
request: postReq(baseCreate),
user: adminUser,
}),
);
expect(res.status).toBe(201);
});
// ===========================================
// Validation failures → 400 + no bare byline left behind
// ===========================================
it("unknown customField slug → 400 VALIDATION_ERROR and no byline row created", async () => {
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { not_a_registered_field: "x" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: { code: "VALIDATION_ERROR" } });
// Validation MUST run before the row insert — otherwise a bad
// customFields payload would leave a bare byline orphaned.
const repo = new BylineRepository(db);
const found = await repo.findBySlug(baseCreate.slug);
expect(found).toBeNull();
});
it("type mismatch (string expected, boolean sent) → 400 and no byline created", async () => {
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { job_title: true },
}),
user: adminUser,
}),
);
expect(res.status).toBe(400);
const repo = new BylineRepository(db);
expect(await repo.findBySlug(baseCreate.slug)).toBeNull();
});
it("select-choice mismatch → 400", async () => {
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { tier: "platinum" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(400);
});
// ===========================================
// D1 crash-recovery on retry (#1174 review BUG 2)
// ===========================================
it("retry after a D1-style crash (bare row, no fields) completes the customFields", async () => {
// Manual orphan-row insert is indistinguishable from a real D1
// crash from the API's perspective — same SQL state.
const repo = new BylineRepository(db);
const orphan = await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: baseCreate.isGuest,
});
expect(orphan.customFields ?? {}).toEqual({});
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: {
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
},
}),
user: adminUser,
}),
);
// Recovery returns 201 like a fresh create; the byline id matching
// the pre-existing row is what distinguishes the two.
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { id: string; customFields?: Record<string, unknown> };
};
expect(json.data.id).toBe(orphan.id);
expect(json.data.customFields).toMatchObject({
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
});
});
it("retry with a different displayName still returns CONFLICT (recovery requires matching payload)", async () => {
const repo = new BylineRepository(db);
await repo.create({ slug: baseCreate.slug, displayName: "Original Name", isGuest: true });
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
displayName: "Different Name",
customFields: { job_title: "Editor" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ error: { code: "CONFLICT" } });
});
it("retry where existing customField value differs from input returns CONFLICT (no overwrite)", async () => {
const repo = new BylineRepository(db);
const seeded = await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
customFields: { job_title: "Original Role" },
});
expect(seeded.customFields?.job_title).toBe("Original Role");
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { job_title: "Attempted Overwrite" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
const reloaded = await repo.findById(seeded.id);
expect(reloaded?.customFields?.job_title).toBe("Original Role");
});
it("retry completes a partial customFields write (D1 mid-loop crash recovery)", async () => {
// Existing row has one of the two requested fields — simulates a
// crash between per-field writes. Recovery must complete the
// missing field.
const repo = new BylineRepository(db);
const partial = await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
customFields: { job_title: "Editor" },
});
expect(partial.customFields?.job_title).toBe("Editor");
expect(partial.customFields?.twitter_handle).toBeUndefined();
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: {
job_title: "Editor",
twitter_handle: "https://twitter.com/jane",
},
}),
user: adminUser,
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { id: string; customFields?: Record<string, unknown> };
};
expect(json.data.id).toBe(partial.id);
expect(json.data.customFields).toMatchObject({
job_title: "Editor",
twitter_handle: "https://twitter.com/jane",
});
});
it("retry with a different bio (fixed-column mismatch) returns CONFLICT", async () => {
const repo = new BylineRepository(db);
await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
bio: "Original bio",
});
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
bio: "Different bio",
customFields: { job_title: "Editor" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
});
it("retry with a different websiteUrl (fixed-column mismatch) returns CONFLICT", async () => {
const repo = new BylineRepository(db);
await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
websiteUrl: "https://example.com/original",
});
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
websiteUrl: "https://example.com/different",
customFields: { job_title: "Editor" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
});
it("retry with translationOf pointing to a different group returns CONFLICT", async () => {
// Existing jane-fr is its own anchor. The retry's `translationOf`
// points to a different anchor — recovery must reject because
// the existing row's translationGroup ≠ sourceGroup.
const repo = new BylineRepository(db);
const janeAnchor = await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
locale: "fr",
});
const bobAnchor = await repo.create({
slug: "bob-anchor",
displayName: "Bob",
locale: "en",
});
expect(janeAnchor.translationGroup).not.toBe(bobAnchor.translationGroup);
const res = await createByline(
buildContext({
db,
request: postReq({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: false,
locale: "fr",
translationOf: bobAnchor.id,
customFields: { job_title: "Editor" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
const reloaded = await repo.findById(janeAnchor.id);
expect(reloaded?.customFields ?? {}).toEqual({});
});
it("retry without translationOf against a non-anchor row returns CONFLICT", async () => {
// Existing row is a translation (translationGroup ≠ its own id).
// A retry without translationOf would, if treated as a fresh
// create, mint its own group — doesn't match, so recovery rejects.
const repo = new BylineRepository(db);
const source = await repo.create({
slug: "source-en",
displayName: "Source",
locale: "en",
});
const translation = await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
locale: "fr",
translationOf: source.id,
});
expect(translation.translationGroup).toBe(source.id);
expect(translation.translationGroup).not.toBe(translation.id);
const res = await createByline(
buildContext({
db,
request: postReq({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: false,
locale: "fr",
customFields: { job_title: "Editor" },
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
const reloaded = await repo.findById(translation.id);
expect(reloaded?.customFields ?? {}).toEqual({});
});
it("retry where input omits a key the existing row stores returns CONFLICT", async () => {
// Caller may have intended to clear the omitted key — that's an
// update, not a recovery. Reject so callers reach for PUT explicitly.
const repo = new BylineRepository(db);
await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
customFields: {
job_title: "Editor",
twitter_handle: "https://twitter.com/jane",
},
});
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { job_title: "Editor" }, // twitter_handle absent
}),
user: adminUser,
}),
);
expect(res.status).toBe(409);
});
it("retry with no customFields in the payload returns CONFLICT (nothing to complete)", async () => {
const repo = new BylineRepository(db);
await repo.create({
slug: baseCreate.slug,
displayName: baseCreate.displayName,
isGuest: true,
});
const res = await createByline(
buildContext({
db,
request: postReq(baseCreate),
user: adminUser,
}),
);
expect(res.status).toBe(409);
});
// ===========================================
// Auth — bylines:manage gate still applies
// ===========================================
it("returns 401 without a session", async () => {
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { job_title: "X" },
}),
user: null,
}),
);
expect(res.status).toBe(401);
});
it("AUTHOR (below bylines:manage) → 403", async () => {
const authorUser = { id: "author-1", role: Role.AUTHOR };
const res = await createByline(
buildContext({
db,
request: postReq({
...baseCreate,
customFields: { job_title: "X" },
}),
user: authorUser,
}),
);
expect(res.status).toBe(403);
});
});