Files
site-builder/craft/src/hooks/useWhpApi.load.test.tsx
T

160 lines
4.4 KiB
TypeScript
Raw Normal View History

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
/**
* PKG-H §5 round-trip coverage: `load()` must restore per-page `seo`
* (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed
* `PageData`, exactly like it already restores `craftState`. Mocks
* `@craftjs/core`'s `useEditor` (same pattern as
* `PageContext.pure-updaters.test.tsx`) since this test only needs
* `query.serialize`/`actions.deserialize` as inert stubs -- it drives
* `load()`, not the live canvas.
*/
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: deserializeMock },
}),
}));
const whpConfig: WhpConfig = {
user: 'testuser',
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
siteName: 'Test Site',
backUrl: '/panel/sites',
isRoot: false,
};
let container: HTMLDivElement;
let root: Root;
interface Captured {
load: ReturnType<typeof useWhpApi>['load'];
pages: ReturnType<typeof usePages>['pages'];
}
function render(): { get: () => Captured } {
container = document.createElement('div');
document.body.appendChild(container);
let captured: Captured | null = null;
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages } = usePages();
captured = { load, pages };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
</EditorConfigProvider>,
);
});
return { get: () => captured! };
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => {
const seoPayload = {
metaTitle: 'Custom Title',
metaDescription: 'A custom description.',
ogTitle: 'Custom OG Title',
ogImage: '/uploads/og.jpg',
twitterCard: 'summary_large_image' as const,
noindex: true,
};
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload },
{ id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload);
// A page with no seo in the payload stays undefined -- back-compat, not
// coerced into an empty object.
expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
unmount();
});
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined();
unmount();
});
});