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

350 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* customFields write surface on the admin bylines PUT route.
*
* Phase 4 of Discussion #1174 extends `PUT /_emdash/api/admin/bylines/{id}`
* to accept a `customFields` map. Per-field type validation lives in
* `BylineRepository.update` (Phase 3); the route is responsible for
* forwarding the field and mapping `EmDashValidationError` to a clean
* 400 `VALIDATION_ERROR`.
*
* Covers:
* - Round-trip: PUT writes, GET reads them back.
* - Unknown slug → 400 `VALIDATION_ERROR` (rejected by repository, not
* leaked as a 500).
* - Type mismatch → 400 `VALIDATION_ERROR`.
* - Select-choice mismatch → 400 `VALIDATION_ERROR`.
* - PUT route still requires `bylines:manage` — Author can't write
* custom fields on someone else's (or any) byline; missing user is
* 401.
* - Reserved-slug writes return 400 (unknown-key path; no registered
* field claims a reserved slug because the registry rejects them).
*/
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,
PUT as putById,
} from "../../../src/astro/routes/api/admin/bylines/[id]/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 putReq(id: string, body: unknown): Request {
return new Request(`http://localhost/_emdash/api/admin/bylines/${id}`, {
method: "PUT",
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 editorUser = { id: "editor-1", role: Role.EDITOR };
const basePut = {
slug: "jane-doe",
displayName: "Jane Doe",
bio: null,
avatarMediaId: null,
websiteUrl: null,
userId: null,
isGuest: true,
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("PUT /admin/bylines/{id} — customFields write surface", () => {
let db: Kysely<Database>;
let bylineId: string;
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 when the
// repository writes a value row keyed by a vanished field id.
// The version-counter coincidentally lands at the same value
// (4 fields × bump-twice = 8) in each test, so the version-based
// invalidation doesn't fire on its own.
resetBylineFieldDefsCacheForTests();
db = await setupTestDatabase();
// Register two custom fields: one translatable (job_title), one
// group-shared (twitter_handle). Different types so we cover the
// type-validation paths.
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: "is_staff",
label: "Staff",
type: "boolean",
translatable: true,
});
await registry.createField({
slug: "tier",
label: "Tier",
type: "select",
translatable: true,
validation: { options: ["bronze", "silver", "gold"] },
});
// Create a byline directly through the repository — the PUT
// route under test is for *updates*, and the create route is out
// of scope here.
const repo = new BylineRepository(db);
const byline = await repo.create({
slug: "jane-doe",
displayName: "Jane Doe",
isGuest: true,
});
bylineId = byline.id;
});
afterEach(async () => {
await teardownTestDatabase(db);
});
// ===========================================
// Round-trip
// ===========================================
it("PUT writes customFields, GET reads them back", async () => {
const putBody = {
...basePut,
customFields: {
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
is_staff: true,
tier: "gold",
},
};
const putRes = await putById(
buildContext({
db,
request: putReq(bylineId, putBody),
params: { id: bylineId },
user: adminUser,
}),
);
expect(putRes.status).toBe(200);
const putJson = (await putRes.json()) as {
data: { customFields?: Record<string, unknown> };
};
expect(putJson.data.customFields).toMatchObject({
job_title: "Senior editor",
twitter_handle: "https://twitter.com/jane",
is_staff: true,
tier: "gold",
});
const getRes = await getById(
buildContext({ db, request: getReq(bylineId), params: { id: bylineId }, 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",
is_staff: true,
tier: "gold",
});
});
it("null customField value clears that slot", async () => {
const writeRes = await putById(
buildContext({
db,
request: putReq(bylineId, { ...basePut, customFields: { job_title: "Editor" } }),
params: { id: bylineId },
user: adminUser,
}),
);
// Assert the initial write landed — otherwise the clear assertion
// below would pass trivially (the key is absent because the value
// was never written, not because the clear cleared it).
expect(writeRes.status).toBe(200);
const clearRes = await putById(
buildContext({
db,
request: putReq(bylineId, { ...basePut, customFields: { job_title: null } }),
params: { id: bylineId },
user: adminUser,
}),
);
expect(clearRes.status).toBe(200);
const getRes = await getById(
buildContext({ db, request: getReq(bylineId), params: { id: bylineId }, user: adminUser }),
);
const getJson = (await getRes.json()) as {
data: { customFields?: Record<string, unknown> };
};
// `null` cleared the row; the key is absent on read (Phase 3 AC).
expect(getJson.data.customFields).not.toHaveProperty("job_title");
});
// ===========================================
// Validation failures → 400
// ===========================================
it("unknown customField slug → 400 VALIDATION_ERROR", async () => {
const res = await putById(
buildContext({
db,
request: putReq(bylineId, {
...basePut,
customFields: { not_a_registered_field: "x" },
}),
params: { id: bylineId },
user: adminUser,
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: { code: "VALIDATION_ERROR" } });
});
it("reserved-slug write returns 400 (no registered field has that slug)", async () => {
// `display_name` is reserved, so no custom field can claim it.
// The repo therefore treats it as an unknown key → 400.
const res = await putById(
buildContext({
db,
request: putReq(bylineId, {
...basePut,
customFields: { display_name: "X" },
}),
params: { id: bylineId },
user: adminUser,
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: { code: "VALIDATION_ERROR" } });
});
it("type mismatch (string expected, boolean sent) → 400", async () => {
const res = await putById(
buildContext({
db,
request: putReq(bylineId, {
...basePut,
customFields: { job_title: true },
}),
params: { id: bylineId },
user: adminUser,
}),
);
expect(res.status).toBe(400);
});
it("select-choice mismatch → 400", async () => {
const res = await putById(
buildContext({
db,
request: putReq(bylineId, {
...basePut,
customFields: { tier: "platinum" }, // not in validation.options
}),
params: { id: bylineId },
user: adminUser,
}),
);
expect(res.status).toBe(400);
});
// ===========================================
// Auth — bylines:manage gate still applies to customFields writes
// ===========================================
it("returns 401 without a session", async () => {
const res = await putById(
buildContext({
db,
request: putReq(bylineId, { ...basePut, customFields: { job_title: "X" } }),
params: { id: bylineId },
user: null,
}),
);
expect(res.status).toBe(401);
});
it("EDITOR can write customFields (bylines:manage = EDITOR)", async () => {
const res = await putById(
buildContext({
db,
request: putReq(bylineId, { ...basePut, customFields: { job_title: "Editor" } }),
params: { id: bylineId },
user: editorUser,
}),
);
expect(res.status).toBe(200);
});
it("AUTHOR (below bylines:manage) → 403", async () => {
const authorUser = { id: "author-1", role: Role.AUTHOR };
const res = await putById(
buildContext({
db,
request: putReq(bylineId, { ...basePut, customFields: { job_title: "X" } }),
params: { id: bylineId },
user: authorUser,
}),
);
expect(res.status).toBe(403);
});
});