Files
emdash-cms--emdash/packages/admin/tests/byline-form-customfields.test.tsx
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

329 lines
12 KiB
TypeScript

/**
* Phase 6 of Discussion #1174 — byline edit form custom-field tests.
*
* PR plan §228: "Component test: register a field via mock API, open
* the form, verify input renders, fill + submit, verify PATCH body
* includes `customFields` with the entered value."
*
* The test stubs the byline + byline-fields APIs, mounts the bylines
* page in a QueryClient + Toast.Provider wrapper, simulates selecting
* an existing byline (which puts the form in edit mode where custom
* fields render), and asserts the PATCH body forwarded to
* `updateByline` includes the typed value.
*
* vitest-browser-react + Playwright actionability: Kumo's
* `Dialog` overlay blocks clicks inside dialog bodies, but the
* bylines page renders the sidebar list as plain `<button>` elements
* (no dialog) and the Save button in the inline right-pane (also no
* dialog). Both are clickable. The custom-field input is a plain Kumo
* `<Input>`, which accepts `.fill()` directly.
*/
import { Toast } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { BylineFieldDefinition } from "../src/lib/api/byline-fields";
import type { BylineSummary } from "../src/lib/api/bylines";
import { render } from "./utils/render.tsx";
// TanStack Router pieces consumed by the bylines route. Mocked to
// avoid wiring a full RouterProvider just for one component test.
vi.mock("@tanstack/react-router", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
return {
...actual,
useSearch: () => ({ locale: undefined }),
useNavigate: () => vi.fn(),
};
});
// Bylines API surface — every call the page makes is mocked. The
// per-test setup below configures fixtures and tracks `updateByline`
// calls so the final assertion can verify the PATCH body shape.
vi.mock("../src/lib/api/bylines", async () => {
const actual =
await vi.importActual<typeof import("../src/lib/api/bylines")>("../src/lib/api/bylines");
return {
...actual,
fetchBylines: vi.fn(),
fetchByline: vi.fn(),
fetchBylineTranslations: vi.fn().mockResolvedValue({ items: [] }),
createByline: vi.fn(),
updateByline: vi.fn(),
deleteByline: vi.fn(),
createBylineTranslation: vi.fn(),
};
});
vi.mock("../src/lib/api/users", async () => {
const actual =
await vi.importActual<typeof import("../src/lib/api/users")>("../src/lib/api/users");
return {
...actual,
fetchUsers: vi.fn().mockResolvedValue({ items: [], nextCursor: undefined }),
};
});
vi.mock("../src/lib/api/byline-fields", async () => {
const actual = await vi.importActual<typeof import("../src/lib/api/byline-fields")>(
"../src/lib/api/byline-fields",
);
return {
...actual,
listBylineFields: vi.fn(),
};
});
// Manifest used by the locale switcher. Single-locale stub keeps the
// multi-locale UI off — irrelevant to this AC.
vi.mock("../src/lib/api/client", async () => {
const actual =
await vi.importActual<typeof import("../src/lib/api/client")>("../src/lib/api/client");
return {
...actual,
fetchManifest: vi.fn().mockResolvedValue({
version: "0.0.0",
hash: "test",
collections: {},
plugins: {},
authMode: "passkey",
taxonomies: [],
}),
};
});
const { fetchBylines, fetchByline, updateByline } = await import("../src/lib/api/bylines");
const { listBylineFields } = await import("../src/lib/api/byline-fields");
const { BylinesPage } = await import("../src/routes/bylines");
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeByline(overrides: Partial<BylineSummary> = {}): BylineSummary {
return {
id: "byline_01",
slug: "jane-doe",
displayName: "Jane Doe",
bio: null,
avatarMediaId: null,
websiteUrl: null,
userId: null,
isGuest: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
locale: "en",
translationGroup: null,
customFields: {},
...overrides,
};
}
function makeField(overrides: Partial<BylineFieldDefinition> = {}): BylineFieldDefinition {
return {
id: "fld_01",
slug: "job_title",
label: "Job title",
type: "string",
required: false,
translatable: true,
validation: null,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function TestWrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<Toast.Provider>{children}</Toast.Provider>
</QueryClientProvider>
);
}
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("BylinesPage — custom-field inputs (Phase 6 of #1174)", () => {
it("renders the custom-field input after selecting a byline", async () => {
const byline = makeByline();
vi.mocked(fetchBylines).mockResolvedValue({ items: [byline], nextCursor: undefined });
vi.mocked(fetchByline).mockResolvedValue(byline);
vi.mocked(listBylineFields).mockResolvedValue({
items: [makeField({ label: "Job title", slug: "job_title", type: "string" })],
});
const screen = await render(
<TestWrapper>
<BylinesPage />
</TestWrapper>,
);
// Edit mode: registered field renders inline (no "Custom fields" header).
const bylineButton = screen.getByRole("button", { name: /Jane Doe/ });
await bylineButton.click();
await expect.element(screen.getByLabelText("Job title")).toBeInTheDocument();
});
it("renders the custom-field input in create mode", async () => {
// Phase 6: create-flow parity — POST accepts customFields now.
vi.mocked(fetchBylines).mockResolvedValue({ items: [], nextCursor: undefined });
vi.mocked(listBylineFields).mockResolvedValue({
items: [makeField({ label: "Job title", slug: "job_title", type: "string" })],
});
const screen = await render(
<TestWrapper>
<BylinesPage />
</TestWrapper>,
);
await expect.element(screen.getByText("Create byline")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Job title")).toBeInTheDocument();
});
it("forwards customFields in the PATCH body on save", async () => {
const byline = makeByline({
customFields: { job_title: "Senior editor" },
});
vi.mocked(fetchBylines).mockResolvedValue({ items: [byline], nextCursor: undefined });
vi.mocked(fetchByline).mockResolvedValue(byline);
vi.mocked(listBylineFields).mockResolvedValue({
items: [makeField({ slug: "job_title", type: "string" })],
});
vi.mocked(updateByline).mockResolvedValue(byline);
const screen = await render(
<TestWrapper>
<BylinesPage />
</TestWrapper>,
);
await screen.getByRole("button", { name: /Jane Doe/ }).click();
// Prefilled value from the byline's customFields hydration.
const input = screen.getByLabelText("Job title");
await expect.element(input).toHaveValue("Senior editor");
// Save without modifying — proves the PATCH body still includes
// the customFields map (the form's `toFormState` populated it
// from the response, and `updateMutation` spreads it).
await screen.getByRole("button", { name: "Save" }).click();
// Vitest-browser-react resolves the mutation asynchronously;
// give the click a microtask before asserting.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(vi.mocked(updateByline)).toHaveBeenCalledTimes(1);
const [bylineId, body] = vi.mocked(updateByline).mock.calls[0]!;
expect(bylineId).toBe(byline.id);
expect(body.customFields).toEqual({ job_title: "Senior editor" });
});
it("omits customFields from the PATCH body when field-defs fail to load", async () => {
// Regression guard for the silent-overwrite scenario flagged in
// the Phase 6 review. When `listBylineFields` errors, the form
// can't render the inputs, so the editor cannot see what
// they'd be saving. The Save button stays enabled so editors
// can still update fixed columns (bio, slug, etc.), but the
// PATCH body must NOT include `customFields` — otherwise the
// hydrated map would round-trip and a "field deleted
// server-side mid-session" race would surface as a 400.
const byline = makeByline({
customFields: { job_title: "Senior editor" },
});
vi.mocked(fetchBylines).mockResolvedValue({ items: [byline], nextCursor: undefined });
vi.mocked(fetchByline).mockResolvedValue(byline);
vi.mocked(listBylineFields).mockRejectedValue(new Error("network blip"));
vi.mocked(updateByline).mockResolvedValue(byline);
const screen = await render(
<TestWrapper>
<BylinesPage />
</TestWrapper>,
);
await screen.getByRole("button", { name: /Jane Doe/ }).click();
// Error surface is shown — editor is told what's happening.
await expect.element(screen.getByText("Couldn't load custom fields.")).toBeInTheDocument();
// Save fixed columns; PATCH body must omit `customFields`.
await screen.getByRole("button", { name: "Save" }).click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(vi.mocked(updateByline)).toHaveBeenCalledTimes(1);
const [, body] = vi.mocked(updateByline).mock.calls[0]!;
expect(body).not.toHaveProperty("customFields");
// Sanity: the fixed columns ARE in the body — proves the Save
// path actually fired and the omission is targeted.
expect(body.slug).toBe(byline.slug);
expect(body.displayName).toBe(byline.displayName);
});
it("renders the right Kumo input for each registered field type", async () => {
const byline = makeByline({
customFields: {
job_title: "Editor",
bio_long: "A long bio.",
homepage: "https://example.com",
is_staff: true,
tier: "gold",
},
});
vi.mocked(fetchBylines).mockResolvedValue({ items: [byline], nextCursor: undefined });
vi.mocked(fetchByline).mockResolvedValue(byline);
vi.mocked(listBylineFields).mockResolvedValue({
items: [
makeField({ id: "f1", slug: "job_title", label: "Job title", type: "string" }),
makeField({ id: "f2", slug: "bio_long", label: "Long bio", type: "text" }),
makeField({ id: "f3", slug: "homepage", label: "Homepage", type: "url" }),
makeField({ id: "f4", slug: "is_staff", label: "Staff", type: "boolean" }),
makeField({
id: "f5",
slug: "tier",
label: "Tier",
type: "select",
validation: { options: ["bronze", "silver", "gold"] },
}),
],
});
const screen = await render(
<TestWrapper>
<BylinesPage />
</TestWrapper>,
);
await screen.getByRole("button", { name: /Jane Doe/ }).click();
// `string` and `url` both render as `<input>`, but their `type`
// attribute differentiates them. `text` renders as `<textarea>`.
// `boolean` is a switch (role=switch). `select` is a Kumo Select
// rendered as a combobox-like button (role=combobox).
await expect.element(screen.getByLabelText("Job title")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Long bio")).toBeInTheDocument();
await expect.element(screen.getByLabelText("Homepage")).toBeInTheDocument();
await expect.element(screen.getByRole("switch", { name: "Staff" })).toBeInTheDocument();
// `getByText('gold')` would resolve to both the button label and
// the open listbox option (Kumo Select pre-mounts the listbox in
// the DOM). Assert on the Tier label + the combobox role to
// prove the select rendered without overlapping the listbox.
await expect.element(screen.getByText("Tier")).toBeInTheDocument();
});
});