160 lines
4.4 KiB
TypeScript
160 lines
4.4 KiB
TypeScript
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();
|
||
|
|
});
|
||
|
|
});
|