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['load']; pages: ReturnType['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( , ); }); 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(); }); });