Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createBlock } from "@wordpress/blocks";
import { buildBlocksFromOutline } from "../../../src/ai-content-planner/helpers/build-blocks-from-outline";

jest.mock( "@wordpress/blocks", () => ( {
createBlock: jest.fn( ( type, attrs ) => ( { type, attrs } ) ),
} ) );

describe( "buildBlocksFromOutline", () => {
beforeEach( () => {
createBlock.mockClear();
} );

it( "returns an empty array for an empty outline", () => {
const result = buildBlocksFromOutline( [] );
expect( result ).toEqual( [] );
expect( createBlock ).not.toHaveBeenCalled();
} );

it( "creates three blocks per section: heading, content-suggestion, paragraph", () => {
const outline = [ { heading: "Introduction", contentNotes: [ "Note 1", "Note 2" ] } ];

const result = buildBlocksFromOutline( outline );

expect( result ).toHaveLength( 3 );
expect( createBlock ).toHaveBeenCalledWith( "core/heading", { content: "Introduction", level: 2 } );
expect( createBlock ).toHaveBeenCalledWith( "yoast-seo/content-suggestion", { suggestions: [ "Note 1", "Note 2" ] } );
expect( createBlock ).toHaveBeenCalledWith( "core/paragraph" );
Comment on lines +13 to +27
} );

it( "creates three blocks for each section in a multi-section outline", () => {
const outline = [
{ heading: "Section 1", contentNotes: [ "Note A" ] },
{ heading: "Section 2", contentNotes: [ "Note B", "Note C" ] },
];

const result = buildBlocksFromOutline( outline );

expect( result ).toHaveLength( 6 );
expect( createBlock ).toHaveBeenCalledTimes( 6 );
} );

it( "preserves section order in the output blocks", () => {
const outline = [
{ heading: "First", contentNotes: [] },
{ heading: "Second", contentNotes: [] },
];

const result = buildBlocksFromOutline( outline );

expect( result[ 0 ] ).toEqual( { type: "core/heading", attrs: { content: "First", level: 2 } } );
expect( result[ 1 ] ).toEqual( { type: "yoast-seo/content-suggestion", attrs: { suggestions: [] } } );
expect( result[ 3 ] ).toEqual( { type: "core/heading", attrs: { content: "Second", level: 2 } } );
} );

it( "uses heading level 2 for all section headings", () => {
const outline = [
{ heading: "A", contentNotes: [] },
{ heading: "B", contentNotes: [] },
];

buildBlocksFromOutline( outline );

const headingCalls = createBlock.mock.calls.filter( ( [ type ] ) => type === "core/heading" );
headingCalls.forEach( ( [ , attrs ] ) => {
expect( attrs.level ).toBe( 2 );
} );
} );

it( "passes contentNotes as suggestions to the content-suggestion block", () => {
const contentNotes = [ "Use examples", "Add statistics", "Include a CTA" ];
const outline = [ { heading: "Body", contentNotes } ];

buildBlocksFromOutline( outline );

expect( createBlock ).toHaveBeenCalledWith( "yoast-seo/content-suggestion", { suggestions: contentNotes } );
} );
} );
166 changes: 166 additions & 0 deletions packages/js/tests/ai-content-planner/helpers/fetch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import apiFetch from "@wordpress/api-fetch";
import { ABORTED_ERROR, contentPlannerFetch } from "../../../src/ai-content-planner/helpers/fetch";

jest.mock( "@wordpress/api-fetch" );

/**
* Returns a mock apiFetch implementation that rejects with an AbortError
* when the provided signal fires or is already aborted.
*
* @param {AbortSignal} signal The signal to listen on.
Comment thread
vraja-pro marked this conversation as resolved.
Outdated
* @returns {Promise} A promise that rejects on abort.
*/
const abortableMock = () => jest.fn( ( options ) => {
if ( options.signal.aborted ) {
return Promise.reject( new DOMException( "Aborted", "AbortError" ) );
}
return new Promise( ( _, reject ) => {
options.signal.addEventListener( "abort", () => {
reject( new DOMException( "Aborted", "AbortError" ) );
} );
} );
} );

describe( "contentPlannerFetch", () => {
beforeEach( () => {
jest.useFakeTimers();
apiFetch.mockReset();
} );

afterEach( () => {
jest.useRealTimers();
} );

it( "returns parsed JSON on a successful response", async() => {
const payload = { idea: "content plan" };
apiFetch.mockResolvedValue( { json: () => Promise.resolve( payload ) } );

const result = await contentPlannerFetch( { path: "/yoast/v1/test" } );

expect( result ).toEqual( payload );
} );

it( "defaults to the GET method when none is specified", async() => {
apiFetch.mockResolvedValue( { json: () => Promise.resolve( {} ) } );

await contentPlannerFetch( { path: "/yoast/v1/test" } );

expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { method: "GET" } ) );
} );

it( "passes the method and data in the fetch options for POST requests", async() => {
apiFetch.mockResolvedValue( { json: () => Promise.resolve( {} ) } );

await contentPlannerFetch( { path: "/yoast/v1/test", method: "POST", data: { topic: "SEO" } } );

expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( {
path: "/yoast/v1/test",
method: "POST",
data: { topic: "SEO" },
parse: false,
} ) );
} );

it( "does not include data in the fetch options when it is not provided", async() => {
apiFetch.mockResolvedValue( { json: () => Promise.resolve( {} ) } );

await contentPlannerFetch( { path: "/yoast/v1/test" } );

const [ options ] = apiFetch.mock.calls[ 0 ];
expect( options ).not.toHaveProperty( "data" );
} );

it( "uses the provided AbortController's signal", async() => {
const controller = new AbortController();
apiFetch.mockResolvedValue( { json: () => Promise.resolve( {} ) } );

await contentPlannerFetch( { path: "/yoast/v1/test", abortController: controller } );

expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { signal: controller.signal } ) );
} );

it( "throws a timeout error when the internal timer fires before the response arrives", async() => {
apiFetch.mockImplementation( abortableMock() );

const fetchPromise = contentPlannerFetch( { path: "/yoast/v1/test" } );
jest.runAllTimers();

await expect( fetchPromise ).rejects.toEqual( {
errorCode: 408,
errorIdentifier: "",
errorMessage: "timeout",
} );
} );

it( "throws ABORTED_ERROR when the caller aborts the request before the timeout fires", async() => {
const controller = new AbortController();
apiFetch.mockImplementation( abortableMock() );

const fetchPromise = contentPlannerFetch( { path: "/yoast/v1/test", abortController: controller } );
// Abort before timers advance — isTimeout stays false.
controller.abort();

await expect( fetchPromise ).rejects.toEqual( ABORTED_ERROR );
} );

it( "throws ABORTED_ERROR when a pre-aborted controller is supplied", async() => {
const controller = new AbortController();
controller.abort();
apiFetch.mockImplementation( abortableMock() );

await expect( contentPlannerFetch( { path: "/yoast/v1/test", abortController: controller } ) ).rejects.toEqual( ABORTED_ERROR );
} );

it( "throws a structured error with status and body fields on an HTTP error response", async() => {
const errorResponse = {
status: 422,
json: () => Promise.resolve( { errorIdentifier: "validation_error", message: "Invalid input" } ),
};
apiFetch.mockRejectedValue( errorResponse );

await expect( contentPlannerFetch( { path: "/yoast/v1/test" } ) ).rejects.toEqual( {
errorCode: 422,
errorIdentifier: "validation_error",
errorMessage: "Invalid input",
missingLicenses: [],
} );
} );

it( "falls back to errorCode 502 when the error response has no status", async() => {
const errorResponse = { json: () => Promise.resolve( {} ) };
apiFetch.mockRejectedValue( errorResponse );

await expect( contentPlannerFetch( { path: "/yoast/v1/test" } ) ).rejects.toEqual( {
errorCode: 502,
errorIdentifier: "",
errorMessage: "",
missingLicenses: [],
} );
} );

it( "includes missingLicenses from the error body", async() => {
const errorResponse = {
status: 403,
json: () => Promise.resolve( { errorIdentifier: "license_required", message: "No license", missingLicenses: [ "premium" ] } ),
};
apiFetch.mockRejectedValue( errorResponse );

const result = await contentPlannerFetch( { path: "/yoast/v1/test" } ).catch( ( e ) => e );

expect( result.missingLicenses ).toEqual( [ "premium" ] );
} );

it( "returns a 502 structured error when the success response body is not valid JSON", async() => {
// Simulate a response whose .json() rejects (malformed body).
// The SyntaxError is not an AbortError, so buildHttpError handles it and
// produces a structured 502 fallback rather than re-throwing the raw error.
apiFetch.mockResolvedValue( { json: () => Promise.reject( new SyntaxError( "Unexpected token" ) ) } );

await expect( contentPlannerFetch( { path: "/yoast/v1/test" } ) ).rejects.toEqual( {
errorCode: 502,
errorIdentifier: "",
errorMessage: "",
missingLicenses: [],
} );
} );
} );
91 changes: 91 additions & 0 deletions packages/js/tests/ai-content-planner/helpers/fields.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
getIsBannerDismissedFromInput,
getIsBannerRenderedFromInput,
setBannerDismissedInput,
setBannerRenderedInput,
} from "../../../src/ai-content-planner/helpers/fields";

const DISMISSED_ID = "yoast_wpseo_is_content_planner_banner_dismissed";
const RENDERED_ID = "yoast_wpseo_is_content_planner_banner_rendered";

afterEach( () => {
document.body.innerHTML = "";
} );

describe( "getIsBannerDismissedFromInput", () => {
it( "returns true when the hidden input value is '1'", () => {
document.body.innerHTML = `<input id="${ DISMISSED_ID }" value="1">`;
expect( getIsBannerDismissedFromInput() ).toBe( true );
} );

it( "returns false when the hidden input value is '0'", () => {
document.body.innerHTML = `<input id="${ DISMISSED_ID }" value="0">`;
expect( getIsBannerDismissedFromInput() ).toBe( false );
} );

it( "returns false when the hidden input value is empty", () => {
document.body.innerHTML = `<input id="${ DISMISSED_ID }" value="">`;
expect( getIsBannerDismissedFromInput() ).toBe( false );
} );

it( "returns false when the input element does not exist", () => {
expect( getIsBannerDismissedFromInput() ).toBe( false );
} );
} );

describe( "getIsBannerRenderedFromInput", () => {
it( "returns true when the hidden input value is '1'", () => {
document.body.innerHTML = `<input id="${ RENDERED_ID }" value="1">`;
expect( getIsBannerRenderedFromInput() ).toBe( true );
} );

it( "returns false when the hidden input value is '0'", () => {
document.body.innerHTML = `<input id="${ RENDERED_ID }" value="0">`;
expect( getIsBannerRenderedFromInput() ).toBe( false );
} );

it( "returns false when the hidden input value is empty", () => {
document.body.innerHTML = `<input id="${ RENDERED_ID }" value="">`;
expect( getIsBannerRenderedFromInput() ).toBe( false );
} );

it( "returns false when the input element does not exist", () => {
expect( getIsBannerRenderedFromInput() ).toBe( false );
} );
} );

describe( "setBannerRenderedInput", () => {
it( "sets the input value to '1'", () => {
document.body.innerHTML = `<input id="${ RENDERED_ID }" value="">`;
setBannerRenderedInput();
expect( document.getElementById( RENDERED_ID ).value ).toBe( "1" );
} );

it( "does not throw when the input element does not exist", () => {
expect( () => setBannerRenderedInput() ).not.toThrow();
} );

it( "overwrites an existing non-empty value with '1'", () => {
document.body.innerHTML = `<input id="${ RENDERED_ID }" value="0">`;
setBannerRenderedInput();
expect( document.getElementById( RENDERED_ID ).value ).toBe( "1" );
} );
} );

describe( "setBannerDismissedInput", () => {
it( "sets the input value to '1'", () => {
document.body.innerHTML = `<input id="${ DISMISSED_ID }" value="">`;
setBannerDismissedInput();
expect( document.getElementById( DISMISSED_ID ).value ).toBe( "1" );
} );

it( "does not throw when the input element does not exist", () => {
expect( () => setBannerDismissedInput() ).not.toThrow();
} );

it( "overwrites an existing non-empty value with '1'", () => {
document.body.innerHTML = `<input id="${ DISMISSED_ID }" value="0">`;
setBannerDismissedInput();
expect( document.getElementById( DISMISSED_ID ).value ).toBe( "1" );
} );
} );
Loading
Loading