From 84c8e0f37ef8e8678cd6834f0e2de5d927d5c6fa Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Mon, 4 May 2026 12:49:21 +0300 Subject: [PATCH 1/4] test: add unit tests for ai-content-planner helpers Covers the previously untested build-blocks-from-outline, fetch, fields, and normalize-error helpers with cases for happy paths, edge cases (empty input, missing DOM elements), error handling (timeout, user abort, HTTP errors), and fallback behaviour. --- .../helpers/build-blocks-from-outline.test.js | 77 ++++++++ .../ai-content-planner/helpers/fetch.test.js | 166 ++++++++++++++++++ .../ai-content-planner/helpers/fields.test.js | 91 ++++++++++ .../helpers/normalize-error.test.js | 78 ++++++++ 4 files changed, 412 insertions(+) create mode 100644 packages/js/tests/ai-content-planner/helpers/build-blocks-from-outline.test.js create mode 100644 packages/js/tests/ai-content-planner/helpers/fetch.test.js create mode 100644 packages/js/tests/ai-content-planner/helpers/fields.test.js create mode 100644 packages/js/tests/ai-content-planner/helpers/normalize-error.test.js 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..0f1b0f64cf8 --- /dev/null +++ b/packages/js/tests/ai-content-planner/helpers/fetch.test.js @@ -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. + * @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..92c9c7dc1cb --- /dev/null +++ b/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js @@ -0,0 +1,78 @@ +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( "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 ); + } ); +} ); From b779d65e491c2e957d2f2365123ba2e5b575d2ff Mon Sep 17 00:00:00 2001 From: Vraja Das <65466507+vraja-pro@users.noreply.github.com> Date: Tue, 5 May 2026 10:06:37 +0300 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/js/tests/ai-content-planner/helpers/fetch.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/js/tests/ai-content-planner/helpers/fetch.test.js b/packages/js/tests/ai-content-planner/helpers/fetch.test.js index 0f1b0f64cf8..ac12f6e1ed7 100644 --- a/packages/js/tests/ai-content-planner/helpers/fetch.test.js +++ b/packages/js/tests/ai-content-planner/helpers/fetch.test.js @@ -5,9 +5,8 @@ jest.mock( "@wordpress/api-fetch" ); /** * Returns a mock apiFetch implementation that rejects with an AbortError - * when the provided signal fires or is already aborted. + * when `options.signal` fires or is already aborted. * - * @param {AbortSignal} signal The signal to listen on. * @returns {Promise} A promise that rejects on abort. */ const abortableMock = () => jest.fn( ( options ) => { From 01bb3a719a389fec381c443474309ffc39e0eb62 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 10:09:19 +0300 Subject: [PATCH 3/4] fix js doc comment for buildBlocksFromOutline --- .../src/ai-content-planner/helpers/build-blocks-from-outline.js | 1 - 1 file changed, 1 deletion(-) 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. From 4c9b4e77533d137bec647d195b2eba44398d9cf6 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 10:52:21 +0300 Subject: [PATCH 4/4] fix: handle raw string payloads in normalizeError --- .../js/src/ai-content-planner/helpers/normalize-error.js | 8 ++++++-- .../ai-content-planner/helpers/normalize-error.test.js | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) 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/normalize-error.test.js b/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js index 92c9c7dc1cb..2ba6daf0f4b 100644 --- a/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js +++ b/packages/js/tests/ai-content-planner/helpers/normalize-error.test.js @@ -37,6 +37,13 @@ describe( "normalizeError", () => { } ); } ); + 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,