diff --git a/packages/js/src/ai-content-planner/helpers/build-blocks-from-outline.js b/packages/js/src/ai-content-planner/helpers/build-blocks-from-outline.js
index e8f924c45c7..72d85e4430a 100644
--- a/packages/js/src/ai-content-planner/helpers/build-blocks-from-outline.js
+++ b/packages/js/src/ai-content-planner/helpers/build-blocks-from-outline.js
@@ -4,7 +4,6 @@ import { createBlock } from "@wordpress/blocks";
* Builds the list of blocks from a content outline.
*
* For each section: heading block, content suggestion block, empty paragraph block.
- * At the end: FAQ content suggestion block, empty FAQ block.
*
* @param {Object} outline The content outline from the store.
* @returns {Array} The list of blocks to insert into the editor.
diff --git a/packages/js/src/ai-content-planner/helpers/normalize-error.js b/packages/js/src/ai-content-planner/helpers/normalize-error.js
index 9c006220758..24ade4828ec 100644
--- a/packages/js/src/ai-content-planner/helpers/normalize-error.js
+++ b/packages/js/src/ai-content-planner/helpers/normalize-error.js
@@ -1,4 +1,4 @@
-import { mapValues } from "lodash";
+import { mapValues, isObject, isString } from "lodash";
/**
* Normalizes an error payload to the structured shape expected by `ContentPlannerError`.
@@ -17,7 +17,11 @@ export const normalizeError = ( payload ) => {
// Bad gateway error will not have a payload, so we set a default error.
// Normalize errorMessage to also accept the plain Error `message` property.
- const source = { ...( payload || {} ), errorMessage: payload?.errorMessage || payload?.message };
+ const payloadObject = isObject( payload ) ? payload : {};
+ if ( isString( payload ) ) {
+ payloadObject.message = payload;
+ }
+ const source = { ...payloadObject, errorMessage: payloadObject?.errorMessage || payloadObject?.message };
return mapValues( defaultError, ( defaultVal, key ) => source[ key ] || defaultVal );
};
diff --git a/packages/js/tests/ai-content-planner/helpers/build-blocks-from-outline.test.js b/packages/js/tests/ai-content-planner/helpers/build-blocks-from-outline.test.js
new file mode 100644
index 00000000000..56032e6f563
--- /dev/null
+++ b/packages/js/tests/ai-content-planner/helpers/build-blocks-from-outline.test.js
@@ -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" );
+ } );
+
+ 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 } );
+ } );
+} );
diff --git a/packages/js/tests/ai-content-planner/helpers/fetch.test.js b/packages/js/tests/ai-content-planner/helpers/fetch.test.js
new file mode 100644
index 00000000000..ac12f6e1ed7
--- /dev/null
+++ b/packages/js/tests/ai-content-planner/helpers/fetch.test.js
@@ -0,0 +1,165 @@
+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 `options.signal` fires or is already aborted.
+ *
+ * @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: [],
+ } );
+ } );
+} );
diff --git a/packages/js/tests/ai-content-planner/helpers/fields.test.js b/packages/js/tests/ai-content-planner/helpers/fields.test.js
new file mode 100644
index 00000000000..d7e05e7a0d6
--- /dev/null
+++ b/packages/js/tests/ai-content-planner/helpers/fields.test.js
@@ -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 = ``;
+ expect( getIsBannerDismissedFromInput() ).toBe( true );
+ } );
+
+ it( "returns false when the hidden input value is '0'", () => {
+ document.body.innerHTML = ``;
+ expect( getIsBannerDismissedFromInput() ).toBe( false );
+ } );
+
+ it( "returns false when the hidden input value is empty", () => {
+ document.body.innerHTML = ``;
+ 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 = ``;
+ expect( getIsBannerRenderedFromInput() ).toBe( true );
+ } );
+
+ it( "returns false when the hidden input value is '0'", () => {
+ document.body.innerHTML = ``;
+ expect( getIsBannerRenderedFromInput() ).toBe( false );
+ } );
+
+ it( "returns false when the hidden input value is empty", () => {
+ document.body.innerHTML = ``;
+ 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 = ``;
+ 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 = ``;
+ setBannerRenderedInput();
+ expect( document.getElementById( RENDERED_ID ).value ).toBe( "1" );
+ } );
+} );
+
+describe( "setBannerDismissedInput", () => {
+ it( "sets the input value to '1'", () => {
+ document.body.innerHTML = ``;
+ 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 = ``;
+ setBannerDismissedInput();
+ expect( document.getElementById( DISMISSED_ID ).value ).toBe( "1" );
+ } );
+} );
diff --git a/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js b/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js
new file mode 100644
index 00000000000..2ba6daf0f4b
--- /dev/null
+++ b/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js
@@ -0,0 +1,85 @@
+import { normalizeError } from "../../../src/ai-content-planner/helpers/normalize-error";
+
+const DEFAULT_ERROR = {
+ errorCode: 502,
+ errorIdentifier: "",
+ errorMessage: "",
+ missingLicenses: [],
+};
+
+describe( "normalizeError", () => {
+ it( "returns all defaults for a null payload", () => {
+ expect( normalizeError( null ) ).toEqual( DEFAULT_ERROR );
+ } );
+
+ it( "returns all defaults for an undefined payload", () => {
+ expect( normalizeError( undefined ) ).toEqual( DEFAULT_ERROR );
+ } );
+
+ it( "returns all defaults for an empty object payload", () => {
+ expect( normalizeError( {} ) ).toEqual( DEFAULT_ERROR );
+ } );
+
+ it( "maps the message property of a plain Error instance to errorMessage", () => {
+ const error = new Error( "Something went wrong" );
+ const result = normalizeError( error );
+ expect( result.errorMessage ).toBe( "Something went wrong" );
+ expect( result.errorCode ).toBe( 502 );
+ } );
+
+ it( "uses errorMessage from the payload when present", () => {
+ const result = normalizeError( { errorCode: 404, errorIdentifier: "not_found", errorMessage: "Not found" } );
+ expect( result ).toEqual( {
+ errorCode: 404,
+ errorIdentifier: "not_found",
+ errorMessage: "Not found",
+ missingLicenses: [],
+ } );
+ } );
+
+ it( "maps a raw string payload to errorMessage", () => {
+ const result = normalizeError( "Something went wrong" );
+ expect( result.errorMessage ).toBe( "Something went wrong" );
+ expect( result.errorCode ).toBe( 502 );
+ expect( result.errorIdentifier ).toBe( "" );
+ expect( result.missingLicenses ).toEqual( [] );
+ } );
+ it( "fills in defaults for each missing field individually", () => {
+ expect( normalizeError( { errorCode: 500 } ) ).toEqual( {
+ errorCode: 500,
+ errorIdentifier: "",
+ errorMessage: "",
+ missingLicenses: [],
+ } );
+ } );
+
+ it( "includes missingLicenses from the payload", () => {
+ const result = normalizeError( {
+ errorCode: 403,
+ errorIdentifier: "license_required",
+ errorMessage: "No license",
+ missingLicenses: [ "premium" ],
+ } );
+ expect( result.missingLicenses ).toEqual( [ "premium" ] );
+ } );
+
+ it( "prefers errorMessage over message when both are present", () => {
+ const result = normalizeError( { errorMessage: "Specific message", message: "Generic message" } );
+ expect( result.errorMessage ).toBe( "Specific message" );
+ } );
+
+ it( "falls back to message when errorMessage is absent", () => {
+ const result = normalizeError( { message: "Fallback message" } );
+ expect( result.errorMessage ).toBe( "Fallback message" );
+ } );
+
+ it( "returns a full error object unchanged when all fields are provided", () => {
+ const fullError = {
+ errorCode: 422,
+ errorIdentifier: "validation_failed",
+ errorMessage: "Invalid request",
+ missingLicenses: [ "woo" ],
+ };
+ expect( normalizeError( fullError ) ).toEqual( fullError );
+ } );
+} );