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 ); + } ); +} );