Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions resources/js/visual-editor/site-editor/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
SiteEditorApiError,
createEntity,
deleteEntity,
fetchEntity,
listEntities,
updateEntity,
} from '../api-client';

const API_BASE = '/visual-editor/api';

function mockFetch(
response: Response | (() => Response)
): ReturnType<typeof vi.fn> {
const fn = vi.fn(async (): Promise<Response> =>
typeof response === 'function' ? response() : response
);

vi.stubGlobal('fetch', fn);

return fn;
}

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

beforeEach(() => {
// Clear any inherited CSRF meta between tests.
document.querySelectorAll('meta[name="csrf-token"]').forEach((node) => {
node.remove();
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('listEntities', () => {
it('requests the paginated envelope and applies filters', async () => {
const body = {
data: [{ id: 1, slug: 'single', title: { rendered: 'Single' } }],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 1 },
};
const fetchMock = mockFetch(jsonResponse(body));

const result = await listEntities(
{ apiBase: API_BASE },
'template',
{ perPage: 10, theme: 'my-theme', status: 'publish' }
);

expect(result.data).toHaveLength(1);
expect(result.meta.total).toBe(1);

const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();

const url = firstCall![0] as string;
expect(url).toContain('/visual-editor/api/templates');
expect(url).toContain('per_page=10');
expect(url).toContain('theme=my-theme');
expect(url).toContain('status=publish');
});

it('hits the template-parts endpoint with an area filter', async () => {
const fetchMock = mockFetch(
jsonResponse({
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
})
);

await listEntities(
{ apiBase: API_BASE },
'template-part',
{ area: 'header' }
);

const url = fetchMock.mock.calls[0]?.[0] as string;
expect(url).toContain('/visual-editor/api/template-parts');
expect(url).toContain('area=header');
});

it('raises SiteEditorApiError with the parsed body on non-2xx responses', async () => {
mockFetch(
jsonResponse({ message: 'Nope', errors: { slug: ['required'] } }, 422)
);

await expect(
listEntities({ apiBase: API_BASE }, 'template')
).rejects.toBeInstanceOf(SiteEditorApiError);
});
});

describe('fetchEntity', () => {
it('returns the single-record response', async () => {
mockFetch(
jsonResponse({
id: 42,
slug: 'page',
title: { rendered: 'Page' },
content: { raw: '', blocks: [] },
})
);

const record = await fetchEntity({ apiBase: API_BASE }, 'template', 42);

expect(record.id).toBe(42);
expect(record.slug).toBe('page');
});
});

describe('createEntity', () => {
it('POSTs with the CSRF token and Content-Type headers', async () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csrf-token');
meta.setAttribute('content', 'test-token');
document.head.appendChild(meta);

const fetchMock = mockFetch(
jsonResponse({ id: 1, slug: 'single', title: { rendered: '' } }, 201)
);

await createEntity({ apiBase: API_BASE }, 'template', {
slug: 'single',
theme: 'default',
});

const [, init] = fetchMock.mock.calls[0] ?? [];
expect(init?.method).toBe('POST');
const headers = init?.headers as Record<string, string>;
expect(headers['X-CSRF-TOKEN']).toBe('test-token');
expect(headers['Content-Type']).toBe('application/json');
});
});

describe('updateEntity', () => {
it('PUTs with the body payload', async () => {
const fetchMock = mockFetch(
jsonResponse({ id: 1, slug: 'single', title: { rendered: '' } })
);

await updateEntity({ apiBase: API_BASE }, 'template', 1, {
content: { raw: '', blocks: [{ name: 'core/paragraph' }] },
});

const [, init] = fetchMock.mock.calls[0] ?? [];
expect(init?.method).toBe('PUT');

const parsedBody = JSON.parse(init?.body as string) as {
content: { blocks: unknown[] };
};
expect(parsedBody.content.blocks).toHaveLength(1);
});

it('surfaces 422 validation errors on the error object', async () => {
mockFetch(
jsonResponse(
{
message: 'The given data was invalid.',
errors: { slug: ['Slug already in use.'] },
},
422
)
);

try {
await updateEntity({ apiBase: API_BASE }, 'template', 1, {});
expect.unreachable();
} catch (error: unknown) {
expect(error).toBeInstanceOf(SiteEditorApiError);
const typed = error as SiteEditorApiError;
expect(typed.status).toBe(422);
expect(typed.validationErrors?.slug?.[0]).toBe('Slug already in use.');
}
});
});

describe('deleteEntity', () => {
it('issues a DELETE and resolves on 204', async () => {
const fetchMock = mockFetch(
new Response(null, { status: 204 })
);

await deleteEntity({ apiBase: API_BASE }, 'template-part', 7);

const [, init] = fetchMock.mock.calls[0] ?? [];
expect(init?.method).toBe('DELETE');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const CREATE_MOCK = vi.fn();

vi.mock('../api-client', async () => {
const actual = await vi.importActual<typeof import('../api-client')>(
'../api-client'
);

return {
...actual,
createEntity: (...args: unknown[]) => CREATE_MOCK(...args),
};
});

import { TemplateCreateDialog } from '../templates-section';
import { TemplatePartCreateDialog } from '../template-parts-section';

const API_CONFIG = { apiBase: '/visual-editor/api' };

beforeEach(() => {
CREATE_MOCK.mockReset();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('TemplateCreateDialog', () => {
it('renders the kind picker and fallback chain', () => {
render(
<TemplateCreateDialog
apiConfig={API_CONFIG}
defaultTheme="default"
onClose={() => undefined}
onCreated={() => undefined}
/>
);

const select = screen.getByTestId(
'ap-site-editor-new-template-kind'
) as HTMLSelectElement;

expect(select).toBeInTheDocument();
expect(
screen.getByTestId('ap-site-editor-new-template-chain')
).toBeInTheDocument();
});

it('submits the chosen slug + title to the API', async () => {
CREATE_MOCK.mockResolvedValue({
id: 42,
slug: 'page',
title: { rendered: 'About' },
});

const onCreated = vi.fn();
const user = userEvent.setup();

render(
<TemplateCreateDialog
apiConfig={API_CONFIG}
defaultTheme="default"
onClose={() => undefined}
onCreated={onCreated}
/>
);

await user.selectOptions(
screen.getByTestId('ap-site-editor-new-template-kind'),
'page'
);
await user.type(
screen.getByTestId('ap-site-editor-new-template-title'),
'About'
);
await user.click(
screen.getByTestId('ap-site-editor-create-dialog-submit-template')
);

await waitFor(() =>
expect(CREATE_MOCK).toHaveBeenCalledWith(
API_CONFIG,
'template',
expect.objectContaining({
slug: 'page',
title: 'About',
theme: 'default',
})
)
);

expect(onCreated).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 })
);
});

it('surfaces server-side validation errors inline', async () => {
const { SiteEditorApiError } = await vi.importActual<
typeof import('../api-client')
>('../api-client');

CREATE_MOCK.mockRejectedValueOnce(
new SiteEditorApiError('Invalid', 422, {
message: 'Invalid',
errors: { slug: ['Slug in use.'] },
})
);

const user = userEvent.setup();

render(
<TemplateCreateDialog
apiConfig={API_CONFIG}
defaultTheme="default"
onClose={() => undefined}
onCreated={() => undefined}
/>
);

// Switch to custom slug so the slug error has a field to bind to.
await user.selectOptions(
screen.getByTestId('ap-site-editor-new-template-kind'),
'__custom__'
);
await user.type(
screen.getByTestId('ap-site-editor-new-template-slug'),
'existing-slug'
);
await user.click(
screen.getByTestId('ap-site-editor-create-dialog-submit-template')
);

await waitFor(() =>
expect(
screen.getByTestId('ap-site-editor-create-dialog-error-template')
).toBeInTheDocument()
);

expect(screen.getByText('Slug in use.')).toBeInTheDocument();
});
});

describe('TemplatePartCreateDialog', () => {
it('submits with the chosen area', async () => {
CREATE_MOCK.mockResolvedValue({
id: 5,
slug: 'site-header',
area: 'header',
title: { rendered: '' },
});

const onCreated = vi.fn();
const user = userEvent.setup();

render(
<TemplatePartCreateDialog
apiConfig={API_CONFIG}
defaultTheme="default"
onClose={() => undefined}
onCreated={onCreated}
/>
);

await user.type(
screen.getByTestId('ap-site-editor-new-part-slug'),
'site-header'
);
await user.selectOptions(
screen.getByTestId('ap-site-editor-new-part-area'),
'footer'
);
await user.click(
screen.getByTestId(
'ap-site-editor-create-dialog-submit-template-part'
)
);

await waitFor(() =>
expect(CREATE_MOCK).toHaveBeenCalledWith(
API_CONFIG,
'template-part',
expect.objectContaining({
slug: 'site-header',
area: 'footer',
theme: 'default',
})
)
);
});
});
Loading