+ { isLoading &&
}
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index 7a46f98..7438533 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -8,6 +8,11 @@
);
}
+// Also applies in the pre-publish panel, outside the sidebar content wrapper.
+.beehiiv-post-settings-notice {
+ margin-top: 12px;
+}
+
.beehiiv-post-settings-content {
/* stylelint-disable selector-class-pattern -- @wordpress/components */
.components-panel__body {
diff --git a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js
index 100bed7..fcad20b 100644
--- a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js
+++ b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js
@@ -12,6 +12,7 @@ const DEFAULT_CONFIG = {
hasPostTemplate: false,
publicationId: '',
defaultPostTemplateId: '',
+ requireTemplateChoice: false,
canPublishPosts: false,
defaultPublicationId: '',
publications: [],
diff --git a/src/js/editor/post-settings/hooks/use-post-publication.js b/src/js/editor/post-settings/hooks/use-post-publication.js
index 18886ed..fe25f33 100644
--- a/src/js/editor/post-settings/hooks/use-post-publication.js
+++ b/src/js/editor/post-settings/hooks/use-post-publication.js
@@ -16,6 +16,7 @@ import { useEditorPublications } from './use-editor-publications';
* @property {boolean} isDefaultPublication Whether the effective publication is the default.
* @property {boolean} isFallback Whether the saved publication is no longer connected.
* @property {boolean} missingPublication Whether no publication can be used yet.
+ * @property {boolean} requireChoice Whether an editor must choose this post's template.
* @property {boolean} missingTemplate Whether the post still needs a template.
* @property {boolean} isPostReady Whether the post has a publication and a template.
*/
@@ -25,8 +26,11 @@ import { useEditorPublications } from './use-editor-publications';
* @return {PostPublication} Per-post publication state.
*/
export function usePostPublication( beehiivMeta ) {
- const { defaultPublicationId, defaultPostTemplateId } =
- useBeehiivEditorConfig();
+ const {
+ defaultPublicationId,
+ defaultPostTemplateId,
+ requireTemplateChoice,
+ } = useBeehiivEditorConfig();
const publications = useEditorPublications().filter( ( item ) => item?.id );
const storedPublicationId = beehiivMeta?.beehiivPublicationId || '';
const templateId = beehiivMeta?.beehiivPostTemplateId || '';
@@ -45,10 +49,21 @@ export function usePostPublication( beehiivMeta ) {
!! effectivePublicationId &&
effectivePublicationId === defaultPublicationId;
+ // With the required-template setting on, a never-sent post has no automatic template.
+ const requireChoice =
+ requireTemplateChoice && ! beehiivMeta?.newsletterAlreadySent;
+
// On fallback the server uses the default template only.
- const hasTemplate = isFallback
- ? !! defaultPostTemplateId
- : !! templateId || ( isDefaultPublication && !! defaultPostTemplateId );
+ let hasTemplate;
+ if ( requireChoice ) {
+ hasTemplate = ! isFallback && !! templateId;
+ } else if ( isFallback ) {
+ hasTemplate = !! defaultPostTemplateId;
+ } else {
+ hasTemplate =
+ !! templateId ||
+ ( isDefaultPublication && !! defaultPostTemplateId );
+ }
return {
publications,
@@ -57,6 +72,7 @@ export function usePostPublication( beehiivMeta ) {
effectivePublicationId,
isDefaultPublication,
isFallback,
+ requireChoice,
missingPublication: ! effectivePublicationId,
missingTemplate: !! effectivePublicationId && ! hasTemplate,
isPostReady: !! effectivePublicationId && hasTemplate,
diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js
index 636895f..77af8a3 100644
--- a/src/js/editor/post-settings/index.js
+++ b/src/js/editor/post-settings/index.js
@@ -90,6 +90,7 @@ function NewsletterPublicationAndTemplate( { beehiivMeta, postPublication } ) {
onChange={ setBeehiivPostTemplateId }
publicationId={ effectivePublicationId }
isDefaultPublication={ isDefaultPublication }
+ requireChoice={ postPublication.requireChoice }
// Sync never changes a newsletter's template, so it is locked once linked.
disabled={ newsletterAlreadySent }
/>
@@ -367,6 +368,14 @@ function BeehiivSendNewsletterPrePublishPanel() {
postPublishDate
);
+ // Pre-publish panels start collapsed. Open this one when the newsletter
+ // still needs a template, so the warning is visible before publishing.
+ const needsTemplate =
+ sendToNewsletter &&
+ ! newsletterAlreadySent &&
+ isNewsletterReady &&
+ postPublication.missingTemplate;
+
const sendToNewsletterStatus =
sendToNewsletter && canWritePosts
? __( 'Yes', 'beehiiv' )
@@ -384,6 +393,7 @@ function BeehiivSendNewsletterPrePublishPanel() {
>
}
icon={ false }
+ initialOpen={ needsTemplate }
>
{ showSendToggle && (
QA E2E newsletter template selection body.
';
+
+const createdPosts = [];
+
+const b64 = ( value ) =>
+ Buffer.from( JSON.stringify( value ) ).toString( 'base64' );
+
+/**
+ * Runs PHP inside the tests env; the payload travels base64-encoded.
+ *
+ * @param {Object} payload Values exposed to the PHP snippet as `$a`.
+ * @param {string} php PHP code to evaluate.
+ * @return {string} wp-cli output.
+ */
+function wpEvalWith( payload, php ) {
+ return wpCli(
+ `eval '$a = json_decode( base64_decode( "${ b64(
+ payload
+ ) }" ), true ); ${ php }'`
+ );
+}
+
+/**
+ * Seeds a "ready to send" site with templates, plus optional settings.
+ *
+ * @param {Object} [options] Seed options.
+ * @param {boolean} [options.requireTemplate] Turn the required-template setting on.
+ * @param {Array} [options.mocks] Extra `[ needle, response ]` HTTP mocks.
+ */
+function seed( { requireTemplate = false, mocks = [] } = {} ) {
+ wpEvalWith(
+ { requireTemplate, mocks },
+ `beehiiv_e2e_reset_all();
+ beehiiv_e2e_seed_connection();
+ beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );
+ update_option( "beehiiv_settings", [
+ "publication_id" => "${ PUBLICATION_ID }",
+ "post_template_id" => "${ DEFAULT_TEMPLATE }",
+ "require_template_choice" => (bool) $a["requireTemplate"],
+ ] );
+ beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] );
+ beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [
+ [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ],
+ [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ],
+ ] );
+ foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); }
+ beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_nts_created" ] ] ] );
+ beehiiv_e2e_clear_http_log();`
+ );
+}
+
+/**
+ * Creates a post fixture with "Send to newsletter" on.
+ *
+ * @param {string} title Post title.
+ * @param {Object} [options] Options.
+ * @param {Object} [options.meta] Post meta.
+ * @param {string} [options.status] Post status.
+ * @return {string} Post ID.
+ */
+function createPost( title, { meta = {}, status = 'draft' } = {} ) {
+ const id = wpEvalWith(
+ {
+ post: {
+ post_type: 'post',
+ post_title: title,
+ post_content: CONTENT,
+ post_status: status,
+ post_author: 1,
+ },
+ meta: { [ META.send ]: '1', ...meta },
+ },
+ `$id = wp_insert_post( $a["post"] );
+ foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); }
+ beehiiv_e2e_clear_http_log();
+ echo $id . "\\n";`
+ );
+ createdPosts.push( id );
+ return id;
+}
+
+/**
+ * Opens the editor sidebar for a post and waits for the template picker.
+ *
+ * @param {import('@playwright/test').Page} page Playwright page.
+ * @param {string} postId Post ID.
+ * @return {Promise} Sidebar locator.
+ */
+async function openSidebarFor( page, postId ) {
+ await openPostEditor( page, postId );
+ await openBeehiivSidebar( page );
+ const sidebar = page.locator( SIDEBAR );
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( {
+ timeout: 20000,
+ } );
+ return sidebar;
+}
+
+test.describe.configure( { timeout: 150 * 1000 } );
+
+test.beforeAll( async () => {
+ ensurePluginActive();
+} );
+
+test.afterAll( async () => {
+ if ( createdPosts.length ) {
+ wpCliSafe(
+ `eval 'foreach ( array( ${ createdPosts.join(
+ ', '
+ ) } ) as $id ) { wp_delete_post( $id, true ); }'`
+ );
+ }
+ wpCliSafe(
+ `eval 'delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'`
+ );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-001: select a custom template for a post', () => {
+ test( 'AC-001: the Post template select appears when newsletter delivery is on and templates exist', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-001' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toBeVisible();
+ await expect( select ).toBeEnabled();
+ } );
+
+ test( 'AC-002: with the setting off, the site default is preselected and labeled "(default)"', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: false } );
+ const id = createPost( 'QA E2E nts AC-002 off' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toHaveValue( DEFAULT_TEMPLATE );
+ await expect( select.locator( 'option:checked' ) ).toHaveText(
+ 'QA Default Template (default)'
+ );
+ } );
+
+ test( 'AC-002: with the setting on but the post already sent to beehiiv, the site default is preselected and labeled "(default)"', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-002 sent', {
+ status: 'publish',
+ meta: { [ META.postId ]: LINKED_ID },
+ } );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toHaveValue( DEFAULT_TEMPLATE );
+ await expect( select.locator( 'option:checked' ) ).toHaveText(
+ 'QA Default Template (default)'
+ );
+ } );
+
+ test( 'AC-003: help text below the dropdown explains which template is used when Default is selected', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: false } );
+ const id = createPost( 'QA E2E nts AC-003' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const help = sidebar
+ .locator( '.beehiiv-newsletter-template' )
+ .locator( '.components-base-control__help' );
+ await expect( help ).toBeVisible( { timeout: 3000 } );
+ await expect( help ).toContainText( /default/i );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-002: view templates from the beehiiv account', () => {
+ test( 'AC-004: templates are fetched from the REST API when the picker mounts', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-004' );
+ await loginAsAdmin( page );
+
+ const request = page.waitForRequest(
+ ( req ) =>
+ decodeURIComponent( req.url() ).includes(
+ 'beehiiv/v1/post-templates'
+ ) &&
+ req.url().includes( `publication_id=${ PUBLICATION_ID }` ) &&
+ ! req.url().includes( 'refresh=1' ),
+ { timeout: 30000 }
+ );
+ await openPostEditor( page, id );
+ await openBeehiivSidebar( page );
+ await request;
+
+ const sidebar = page.locator( SIDEBAR );
+ const options = sidebar
+ .getByLabel( 'Post template' )
+ .locator( 'option' );
+ await expect( options ).toHaveCount( 2 );
+ } );
+
+ test( 'AC-005: the dropdown is disabled while templates are loading', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-005' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ // Slow the next fetch so the loading window is observable.
+ await page.route( /post-templates/, async ( route ) => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 3000 ) );
+ await route.continue();
+ } );
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeDisabled();
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeEnabled( {
+ timeout: 15000,
+ } );
+ } );
+
+ test( 'AC-006: a loading spinner appears while templates are being fetched', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-006' );
+ await loginAsAdmin( page );
+
+ await page.route( /post-templates/, async ( route ) => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 4000 ) );
+ await route.continue();
+ } );
+ await openPostEditor( page, id );
+ await openBeehiivSidebar( page );
+
+ const sidebar = page.locator( SIDEBAR );
+ await expect(
+ sidebar.locator(
+ '.beehiiv-newsletter-template .components-spinner'
+ )
+ ).toBeVisible( { timeout: 10000 } );
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( {
+ timeout: 20000,
+ } );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-003: refresh templates without reloading', () => {
+ test( 'AC-007: Refresh templates reloads the list from beehiiv', async ( {
+ page,
+ } ) => {
+ seed( {
+ mocks: [
+ [
+ '/post_templates',
+ {
+ body: {
+ data: [
+ {
+ id: DEFAULT_TEMPLATE,
+ name: 'QA Default Template',
+ },
+ {
+ id: OTHER_TEMPLATE,
+ name: 'QA Other Template',
+ },
+ {
+ id: NEW_TEMPLATE,
+ name: 'QA Brand New Template',
+ },
+ ],
+ },
+ },
+ ],
+ ],
+ } );
+ const id = createPost( 'QA E2E nts AC-007' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select.locator( 'option' ) ).toHaveCount( 2 );
+
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+
+ await expect( select.locator( 'option' ) ).toHaveCount( 3 );
+ await expect(
+ select.locator( `option[value="${ NEW_TEMPLATE }"]` )
+ ).toHaveText( 'QA Brand New Template' );
+ } );
+
+ test( 'AC-008: "Templates updated from beehiiv." appears after refresh and disappears after about 4 seconds', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-008' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+ const notice = sidebar.getByText( 'Templates updated from beehiiv.' );
+ await expect( notice ).toBeVisible();
+ const shownAt = Date.now();
+ await expect( notice ).toBeHidden( { timeout: 8000 } );
+ const visibleMs = Date.now() - shownAt;
+ expect( visibleMs ).toBeGreaterThan( 2500 );
+ expect( visibleMs ).toBeLessThan( 6500 );
+ } );
+
+ test( 'AC-009: explanatory text below the refresh button notes that templates are cached', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-009' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await expect(
+ sidebar.locator( '.beehiiv-newsletter-template__refresh-help' )
+ ).toContainText( 'Templates are cached.' );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-004 / US-005: snippet mode', () => {
+ test( 'AC-010: the Snippet newsletter toggle appears below the template selector', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-010' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const toggle = sidebar.getByLabel( 'Snippet newsletter' );
+ await expect( toggle ).toBeVisible();
+ const selectBox = await sidebar
+ .getByLabel( 'Post template' )
+ .boundingBox();
+ const toggleBox = await toggle.boundingBox();
+ expect( toggleBox.y ).toBeGreaterThan( selectBox.y );
+ } );
+
+ test( 'AC-011: enabling snippet mode reveals help text about inserting a More block', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-011' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const moreHelp = sidebar.getByText(
+ 'Insert the "More" block in your post to mark where the snippet ends.'
+ );
+ await expect( moreHelp ).toHaveCount( 0 );
+
+ await sidebar.getByLabel( 'Snippet newsletter' ).check();
+ await expect( moreHelp ).toBeVisible();
+ } );
+
+ test( 'AC-012: the help text explains the More block marks the snippet boundary', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-012', {
+ meta: { [ META.snippet ]: '1' },
+ } );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const help = sidebar.locator( '.beehiiv-post-settings-snippet' );
+ await expect( help ).toContainText( '"More" block' );
+ await expect( help ).toContainText( 'where the snippet ends' );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-006: verify template assignment', () => {
+ test( 'AC-013: a saved template no longer available in beehiiv is still listed and labeled as unavailable', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E nts AC-013', {
+ meta: { [ META.template ]: GONE_TEMPLATE },
+ } );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toHaveValue( GONE_TEMPLATE );
+ const option = select.locator( `option[value="${ GONE_TEMPLATE }"]` );
+ await expect( option ).toHaveCount( 1 );
+ // The PRD says the option is labeled as unavailable, not just shown as a bare ID.
+ await expect( option ).toHaveText( /unavailable/i );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-007: required template prompt', () => {
+ test( 'AC-014: with the setting on and a never-sent post, the field starts empty with "Choose a template"', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-014' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toHaveValue( '' );
+ await expect( select.locator( 'option:checked' ) ).toHaveText(
+ 'Choose a template'
+ );
+ } );
+
+ test( 'AC-015: no template is pre-selected and the "Default (from beehiiv settings)" option is not offered', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-015' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).not.toHaveValue( DEFAULT_TEMPLATE );
+ await expect(
+ select.locator( 'option', {
+ hasText: 'Default (from beehiiv settings)',
+ } )
+ ).toHaveCount( 0 );
+ await expect(
+ select.locator( 'option', { hasText: '(default)' } )
+ ).toHaveCount( 0 );
+ // Real templates stay selectable.
+ await expect(
+ select.locator( `option[value="${ DEFAULT_TEMPLATE }"]` )
+ ).toHaveText( 'QA Default Template' );
+ } );
+
+ test( 'AC-016: help text under the empty field asks the editor to choose a post template', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-016' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await expect(
+ sidebar
+ .locator( '.beehiiv-newsletter-template' )
+ .locator( '.components-base-control__help' )
+ ).toHaveText( REQUIRED_MESSAGE );
+ } );
+
+ test( 'AC-017: a send blocked for a missing template shows an editor error notice with the same message', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-017' );
+
+ // Publish through the real save path; the send is blocked server-side.
+ await loginAsAdmin( page );
+ const nonceRes = await page.request.get(
+ '/wp-admin/admin-ajax.php?action=rest-nonce'
+ );
+ const nonce = ( await nonceRes.text() ).trim();
+ const res = await page.request.fetch( `/wp-json/wp/v2/posts/${ id }`, {
+ method: 'POST',
+ headers: { 'X-WP-Nonce': nonce },
+ data: { status: 'publish' },
+ } );
+ expect( res.status() ).toBe( 200 );
+
+ const sidebar = await openSidebarFor( page, id );
+ const errorNotice = sidebar
+ .locator( '.components-notice.is-error' )
+ .filter( { hasText: REQUIRED_MESSAGE } )
+ .first();
+ await expect( errorNotice ).toBeVisible();
+ } );
+
+ test( 'AC-018: the prompt and help text clear once the editor chooses a template', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts AC-018' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await expect(
+ sidebar.getByText( REQUIRED_MESSAGE ).first()
+ ).toBeVisible();
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await select.selectOption( OTHER_TEMPLATE );
+
+ await expect( select ).toHaveValue( OTHER_TEMPLATE );
+ await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 );
+ await expect(
+ select.locator( 'option', { hasText: 'Choose a template' } )
+ ).toHaveCount( 0 );
+ } );
+
+ test( 'AC-019: with the setting off, or the post already sent, there is no prompt, help text or notice', async ( {
+ page,
+ } ) => {
+ // Setting off, never sent.
+ seed( { requireTemplate: false } );
+ const offId = createPost( 'QA E2E nts AC-019 off' );
+ await loginAsAdmin( page );
+ let sidebar = await openSidebarFor( page, offId );
+ await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 );
+ await expect(
+ sidebar
+ .getByLabel( 'Post template' )
+ .locator( 'option', { hasText: 'Choose a template' } )
+ ).toHaveCount( 0 );
+ await expect( sidebar.getByLabel( 'Post template' ) ).toHaveValue(
+ DEFAULT_TEMPLATE
+ );
+
+ // Setting on, already sent.
+ seed( { requireTemplate: true } );
+ const sentId = createPost( 'QA E2E nts AC-019 sent', {
+ status: 'publish',
+ meta: { [ META.postId ]: LINKED_ID },
+ } );
+ sidebar = await openSidebarFor( page, sentId );
+ await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 );
+ await expect(
+ sidebar
+ .getByLabel( 'Post template' )
+ .locator( 'option', { hasText: 'Choose a template' } )
+ ).toHaveCount( 0 );
+ await expect( sidebar.getByLabel( 'Post template' ) ).toHaveValue(
+ DEFAULT_TEMPLATE
+ );
+ } );
+
+ test( 'publish panel: opens with a spaced error notice when a template is still required', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts publish panel' );
+ await loginAsAdmin( page );
+ await openPostEditor( page, id );
+
+ await page
+ .getByRole( 'button', { name: 'Publish', exact: true } )
+ .first()
+ .click();
+
+ // The panel opens by itself, so the warning is visible without expanding it.
+ const notice = page
+ .locator( '.editor-post-publish-panel' )
+ .locator( '.components-notice.is-error' )
+ .filter( { hasText: REQUIRED_MESSAGE } );
+ await expect( notice ).toBeVisible( { timeout: 20000 } );
+ await expect( notice ).toHaveCSS( 'margin-top', '12px' );
+ } );
+
+ test( 'publish panel: no required-template error once a template is chosen', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E nts publish panel chosen', {
+ meta: { [ META.template ]: OTHER_TEMPLATE },
+ } );
+ await loginAsAdmin( page );
+ await openPostEditor( page, id );
+
+ await page
+ .getByRole( 'button', { name: 'Publish', exact: true } )
+ .first()
+ .click();
+
+ await expect(
+ page.locator( '.editor-post-publish-panel' )
+ ).toBeVisible( { timeout: 20000 } );
+ await expect( page.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 );
+ } );
+} );
diff --git a/tests/e2e/specs/post-template-selection.spec.js b/tests/e2e/specs/post-template-selection.spec.js
new file mode 100644
index 0000000..05912db
--- /dev/null
+++ b/tests/e2e/specs/post-template-selection.spec.js
@@ -0,0 +1,708 @@
+const { test, expect } = require( '@playwright/test' );
+const { loginAs, loginAsAdmin } = require( '../utils/auth' );
+const {
+ openPostEditor,
+ openBeehiivSidebar,
+ saveDraft,
+} = require( '../utils/editor' );
+const {
+ wpCli,
+ wpCliSafe,
+ ensurePluginActive,
+ ensurePrettyPermalinks,
+} = require( '../utils/wp-cli' );
+
+/**
+ * PRD: requirements/07-newsletter-publishing/3-newsletter-template-selection/post-template-selection.prd.md (v1.0)
+ *
+ * Independent behavioral coverage (qa-e2e-author), written from the PRD's
+ * Acceptance Criteria plus the real code at:
+ * - src/js/editor/post-settings/components/newsletter-template-select.js
+ * - includes/Newsletter/PostSettingsBuilder.php
+ * - includes/Newsletter/Sender.php
+ * - includes/REST/PostTemplatesController.php
+ *
+ * Every outbound beehiiv request is answered by the test-only
+ * `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php and
+ * recorded in its request log. Nothing reaches the live API.
+ */
+
+const META = {
+ send: '_beehiiv_send_to_newsletter',
+ postId: '_beehiiv_post_id',
+ template: '_beehiiv_post_template_id',
+ error: '_beehiiv_newsletter_error',
+};
+
+const PUBLICATION_ID = 'pub_qa_e2e_ptsel';
+const DEFAULT_TEMPLATE = 'tpl_qa_e2e_default';
+const OTHER_TEMPLATE = 'tpl_qa_e2e_other';
+const NEW_TEMPLATE = 'tpl_qa_e2e_brand_new';
+const MOCK_CREATED_ID = 'post_qa_e2e_ptsel_created';
+const LINKED_ID = 'post_qa_e2e_ptsel_linked';
+const REQUIRED_MESSAGE =
+ 'Choose a post template before sending this post to beehiiv.';
+const SIDEBAR = '.beehiiv-post-settings';
+const CONTENT =
+ 'QA E2E template selection body.
';
+
+const USERS = {
+ editor: { name: 'qa_e2e_ptsel_editor', pass: 'qa-e2e-ptsel-editor-1!' },
+ author: { name: 'qa_e2e_ptsel_author', pass: 'qa-e2e-ptsel-author-1!' },
+};
+
+const createdPosts = [];
+
+const b64 = ( value ) =>
+ Buffer.from( JSON.stringify( value ) ).toString( 'base64' );
+
+/**
+ * Runs PHP inside the tests env; the payload travels base64-encoded.
+ *
+ * @param {Object} payload Values exposed to the PHP snippet as `$a`.
+ * @param {string} php PHP code to evaluate.
+ * @return {string} wp-cli output.
+ */
+function wpEvalWith( payload, php ) {
+ return wpCli(
+ `eval '$a = json_decode( base64_decode( "${ b64(
+ payload
+ ) }" ), true ); ${ php }'`
+ );
+}
+
+/**
+ * Seeds a "ready to send" site with templates, plus optional settings.
+ *
+ * @param {Object} [options] Seed options.
+ * @param {boolean} [options.requireTemplate] Turn the required-template setting on.
+ * @param {string} [options.defaultTemplate] Site default template ID, or empty for none.
+ * @param {Array} [options.mocks] Extra `[ needle, response ]` HTTP mocks, matched first.
+ */
+function seed( {
+ requireTemplate = false,
+ defaultTemplate = DEFAULT_TEMPLATE,
+ mocks = [],
+} = {} ) {
+ wpEvalWith(
+ { requireTemplate, defaultTemplate, mocks },
+ `beehiiv_e2e_reset_all();
+ beehiiv_e2e_seed_connection();
+ beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );
+ update_option( "beehiiv_settings", [
+ "publication_id" => "${ PUBLICATION_ID }",
+ "post_template_id" => $a["defaultTemplate"],
+ "require_template_choice" => (bool) $a["requireTemplate"],
+ ] );
+ beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] );
+ beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [
+ [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ],
+ [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ],
+ ] );
+ foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); }
+ beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "${ MOCK_CREATED_ID }" ] ] ] );
+ beehiiv_e2e_clear_http_log();`
+ );
+}
+
+/**
+ * Creates a draft post fixture and empties the request log.
+ *
+ * @param {string} title Post title.
+ * @param {Object} [options] Options.
+ * @param {Object} [options.meta] Post meta.
+ * @param {string} [options.status] Post status.
+ * @param {number} [options.author] Author user ID.
+ * @return {string} Post ID.
+ */
+function createPost( title, { meta = {}, status = 'draft', author = 1 } = {} ) {
+ const id = wpEvalWith(
+ {
+ post: {
+ post_type: 'post',
+ post_title: title,
+ post_content: CONTENT,
+ post_status: status,
+ post_author: author,
+ },
+ meta: { [ META.send ]: '1', ...meta },
+ },
+ `$id = wp_insert_post( $a["post"] );
+ foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); }
+ beehiiv_e2e_clear_http_log();
+ echo $id . "\\n";`
+ );
+ createdPosts.push( id );
+ return id;
+}
+
+function getLog() {
+ return JSON.parse(
+ wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]'
+ );
+}
+
+/**
+ * All meta for a post, single values.
+ *
+ * @param {number|string} id Post ID.
+ * @return {Object} Meta keyed by meta key.
+ */
+function getMeta( id ) {
+ const raw = wpCli(
+ `eval '$m = get_post_meta( ${ id } ); $o = []; foreach ( $m as $k => $v ) { $o[ $k ] = $v[0]; } echo wp_json_encode( (object) $o ) . "\\n";'`
+ );
+ return JSON.parse( raw );
+}
+
+const creates = ( log ) =>
+ log.filter(
+ ( r ) =>
+ r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url )
+ );
+const postsRequests = ( log ) =>
+ log.filter( ( r ) => /\/publications\/[^/]+\/posts/.test( r.url ) );
+
+/**
+ * Finds the template ID in a logged create request, wherever it is nested.
+ *
+ * @param {Object} request Logged request.
+ * @return {string|undefined} Template ID sent to beehiiv.
+ */
+function sentTemplate( request ) {
+ let body = request.body;
+ if ( typeof body === 'string' ) {
+ try {
+ body = JSON.parse( body );
+ } catch {
+ return undefined;
+ }
+ }
+ return body?.post_template_id;
+}
+
+/**
+ * Logs in as a user and returns a REST caller bound to that session.
+ *
+ * @param {import('@playwright/test').Page} page Playwright page.
+ * @param {Object} [user] `{ name, pass }`, defaults to admin.
+ * @return {Promise} `( method, path, data ) => { status, body }`.
+ */
+async function restAs( page, user ) {
+ if ( user ) {
+ await loginAs( page, user.name, user.pass );
+ } else {
+ await loginAsAdmin( page );
+ }
+ const nonceRes = await page.request.get(
+ '/wp-admin/admin-ajax.php?action=rest-nonce'
+ );
+ const nonce = ( await nonceRes.text() ).trim();
+ return async ( method, path, data ) => {
+ const res = await page.request.fetch( `/wp-json${ path }`, {
+ method,
+ headers: { 'X-WP-Nonce': nonce },
+ data,
+ } );
+ let body = null;
+ try {
+ body = await res.json();
+ } catch {
+ // Non-JSON response; leave body null.
+ }
+ return { status: res.status(), body };
+ };
+}
+
+function ensureUser( { name, pass }, role ) {
+ let id = wpCliSafe( `user get ${ name } --field=ID` );
+ if ( ! id ) {
+ id = wpCli(
+ `user create ${ name } ${ name }@example.test --role=${ role } --user_pass="${ pass }" --porcelain`
+ );
+ } else {
+ wpCli( `user update ${ id } --user_pass="${ pass }" --role=${ role }` );
+ }
+ return id;
+}
+
+async function openSidebarFor( page, postId ) {
+ await openPostEditor( page, postId );
+ await openBeehiivSidebar( page );
+ const sidebar = page.locator( SIDEBAR );
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( {
+ timeout: 20000,
+ } );
+ return sidebar;
+}
+
+test.describe.configure( { timeout: 150 * 1000 } );
+
+let editorId;
+let authorId;
+
+test.beforeAll( async () => {
+ ensurePluginActive();
+ ensurePrettyPermalinks();
+ editorId = ensureUser( USERS.editor, 'editor' );
+ authorId = ensureUser( USERS.author, 'author' );
+} );
+
+test.afterAll( async () => {
+ wpCliSafe(
+ `eval 'foreach ( array( ${ createdPosts.join(
+ ', '
+ ) } ) as $id ) { delete_post_meta( $id, "${
+ META.postId
+ }" ); wp_delete_post( $id, true ); }
+ delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'`
+ );
+ wpCliSafe( `user delete ${ editorId } --yes` );
+ wpCliSafe( `user delete ${ authorId } --yes` );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-001: select a custom template per post', () => {
+ test( 'AC-001: a post template dropdown appears in the editor post settings', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-001' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toBeVisible();
+ await expect( select ).toBeEnabled();
+ } );
+
+ test( 'AC-002: the dropdown lists every beehiiv template for the configured publication', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-002' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ const options = sidebar
+ .getByLabel( 'Post template' )
+ .locator( 'option' );
+ await expect( options ).toHaveCount( 2 );
+ await expect( options.nth( 0 ) ).toHaveText(
+ 'QA Default Template (default)'
+ );
+ await expect( options.nth( 1 ) ).toHaveText( 'QA Other Template' );
+ } );
+
+ test( 'AC-003: an editor can pick a template and the selection is saved to the post', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-003' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await sidebar
+ .getByLabel( 'Post template' )
+ .selectOption( OTHER_TEMPLATE );
+ await saveDraft( page );
+
+ expect( getMeta( id )[ META.template ] ).toBe( OTHER_TEMPLATE );
+
+ // The saved choice survives a reload of the editor.
+ const reloaded = await openSidebarFor( page, id );
+ await expect( reloaded.getByLabel( 'Post template' ) ).toHaveValue(
+ OTHER_TEMPLATE
+ );
+ } );
+
+ test( 'AC-004: the selected template is included in the payload sent to beehiiv', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-004', {
+ meta: { [ META.template ]: OTHER_TEMPLATE },
+ } );
+ const rest = await restAs( page );
+
+ const res = await rest( 'POST', `/wp/v2/posts/${ id }`, {
+ status: 'publish',
+ } );
+ expect( res.status ).toBe( 200 );
+
+ const sent = creates( getLog() );
+ expect( sent ).toHaveLength( 1 );
+ expect( sentTemplate( sent[ 0 ] ) ).toBe( OTHER_TEMPLATE );
+ expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID );
+ } );
+
+ test( 'AC-010: the dropdown shows which template is used when no selection is made', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-010' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ // No template chosen on the post: the picker points at the site default.
+ const select = sidebar.getByLabel( 'Post template' );
+ await expect( select ).toHaveValue( DEFAULT_TEMPLATE );
+ await expect( select.locator( 'option:checked' ) ).toHaveText(
+ 'QA Default Template (default)'
+ );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-002: refresh the template list', () => {
+ test( 'AC-005: a Refresh templates button appears next to the dropdown', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( 'QA E2E ptsel AC-005' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await expect(
+ sidebar.getByRole( 'button', { name: 'Refresh templates' } )
+ ).toBeVisible();
+ } );
+
+ test( 'AC-006: clicking Refresh fetches the latest templates from beehiiv, bypassing the cache', async ( {
+ page,
+ } ) => {
+ seed( {
+ mocks: [
+ [
+ '/post_templates',
+ {
+ body: {
+ data: [
+ {
+ id: DEFAULT_TEMPLATE,
+ name: 'QA Default Template',
+ },
+ {
+ id: OTHER_TEMPLATE,
+ name: 'QA Other Template',
+ },
+ {
+ id: NEW_TEMPLATE,
+ name: 'QA Brand New Template',
+ },
+ ],
+ },
+ },
+ ],
+ ],
+ } );
+ const id = createPost( 'QA E2E ptsel AC-006' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+ const select = sidebar.getByLabel( 'Post template' );
+
+ // The cached list does not know the new template, and no beehiiv call was needed.
+ await expect( select.locator( 'option' ) ).toHaveCount( 2 );
+ expect(
+ getLog().filter( ( r ) => r.url.includes( '/post_templates' ) )
+ ).toHaveLength( 0 );
+
+ const refreshRequest = page.waitForRequest(
+ ( req ) =>
+ req.url().includes( 'post-templates' ) &&
+ req.url().includes( 'refresh=1' )
+ );
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+ await refreshRequest;
+
+ await expect( select.locator( 'option' ) ).toHaveCount( 3 );
+ await expect(
+ select.locator( `option[value="${ NEW_TEMPLATE }"]` )
+ ).toHaveText( 'QA Brand New Template' );
+ expect(
+ getLog().filter( ( r ) => r.url.includes( '/post_templates' ) )
+ .length
+ ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'AC-007: a success message appears briefly after templates are refreshed', async ( {
+ page,
+ } ) => {
+ seed( {
+ mocks: [
+ [
+ '/post_templates',
+ {
+ body: {
+ data: [
+ {
+ id: DEFAULT_TEMPLATE,
+ name: 'QA Default Template',
+ },
+ ],
+ },
+ },
+ ],
+ ],
+ } );
+ const id = createPost( 'QA E2E ptsel AC-007' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+ const notice = sidebar.getByText( 'Templates updated from beehiiv.' );
+ await expect( notice ).toBeVisible();
+ // "Briefly": it goes away on its own.
+ await expect( notice ).toBeHidden( { timeout: 10000 } );
+ } );
+
+ test( 'AC-008: a loading state is shown while templates are being fetched', async ( {
+ page,
+ } ) => {
+ seed( {
+ mocks: [
+ [
+ '/post_templates',
+ {
+ delay_ms: 3000,
+ body: {
+ data: [
+ {
+ id: DEFAULT_TEMPLATE,
+ name: 'QA Default Template',
+ },
+ {
+ id: OTHER_TEMPLATE,
+ name: 'QA Other Template',
+ },
+ ],
+ },
+ },
+ ],
+ ],
+ } );
+ const id = createPost( 'QA E2E ptsel AC-008' );
+ await loginAsAdmin( page );
+ const sidebar = await openSidebarFor( page, id );
+
+ await sidebar
+ .getByRole( 'button', { name: 'Refresh templates' } )
+ .click();
+ const busy = sidebar.getByRole( 'button', { name: 'Refreshing…' } );
+ await expect( busy ).toBeVisible();
+ await expect( busy ).toBeDisabled();
+ await expect( sidebar.getByLabel( 'Post template' ) ).toBeDisabled();
+
+ // Once the fetch finishes the normal controls return.
+ await expect(
+ sidebar.getByRole( 'button', { name: 'Refresh templates' } )
+ ).toBeEnabled( { timeout: 15000 } );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-003: site default template', () => {
+ test( 'AC-009: with no custom template and the required setting off, the site default is used for sending', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: false } );
+ const id = createPost( 'QA E2E ptsel AC-009 off' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+ const sent = creates( getLog() );
+ expect( sent ).toHaveLength( 1 );
+ expect( sentTemplate( sent[ 0 ] ) ).toBe( DEFAULT_TEMPLATE );
+ } );
+
+ test( 'AC-009: with the required setting on, a post already sent to beehiiv is not blocked', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E ptsel AC-009 sent', {
+ status: 'publish',
+ meta: { [ META.postId ]: LINKED_ID, [ META.send ]: '' },
+ } );
+ const rest = await restAs( page );
+
+ const res = await rest( 'POST', `/wp/v2/posts/${ id }`, {
+ title: 'QA E2E ptsel AC-009 sent (edited)',
+ } );
+ expect( res.status ).toBe( 200 );
+
+ const meta = getMeta( id );
+ expect( meta[ META.error ] ).toBeUndefined();
+ const log = getLog();
+ expect( log.some( ( r ) => r.method === 'PATCH' ) ).toBe( true );
+ // If the update payload carries a template, it is the site default.
+ for ( const r of log.filter( ( x ) => x.method === 'PATCH' ) ) {
+ const t = sentTemplate( r );
+ if ( t !== undefined ) {
+ expect( t ).toBe( DEFAULT_TEMPLATE );
+ }
+ }
+ } );
+
+ test( 'AC-011: a post fails to send with a clear error when no template is available', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: false, defaultTemplate: '' } );
+ const id = createPost( 'QA E2E ptsel AC-011' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+ expect( creates( getLog() ) ).toHaveLength( 0 );
+ const meta = getMeta( id );
+ expect( meta[ META.postId ] ).toBeUndefined();
+ expect( meta[ META.error ] ).toContain(
+ 'Choose a default post template'
+ );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-004: require an editor-chosen template', () => {
+ test( 'AC-012: with the setting on and a never-sent post, no template is chosen automatically and the default is not a fallback', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E ptsel AC-012' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+
+ const meta = getMeta( id );
+ // Nothing was stored for the post, and nothing carrying the default went out.
+ expect( meta[ META.template ] ).toBeUndefined();
+ expect( postsRequests( getLog() ) ).toHaveLength( 0 );
+ expect( meta[ META.postId ] ).toBeUndefined();
+ } );
+
+ test( 'AC-013: sending is blocked until an editor chooses a template, even with a default configured', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E ptsel AC-013' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+ expect( creates( getLog() ) ).toHaveLength( 0 );
+ expect( getMeta( id )[ META.postId ] ).toBeUndefined();
+ } );
+
+ test( 'AC-014: a blocked send shows the required message and makes no beehiiv request', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E ptsel AC-014' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+
+ expect( getMeta( id )[ META.error ] ).toContain( REQUIRED_MESSAGE );
+ // No request of any kind went to beehiiv's posts endpoints.
+ expect( postsRequests( getLog() ) ).toHaveLength( 0 );
+ } );
+
+ for ( const role of [ 'administrator', 'editor', 'author' ] ) {
+ test( `AC-015: the rule applies to the ${ role } role`, async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const user = role === 'administrator' ? null : USERS[ role ];
+ const userId = {
+ administrator: 1,
+ editor: editorId,
+ author: authorId,
+ }[ role ];
+ const id = createPost( `QA E2E ptsel AC-015 ${ role }`, {
+ author: userId,
+ } );
+ const rest = await restAs( page, user );
+
+ const res = await rest( 'POST', `/wp/v2/posts/${ id }`, {
+ status: 'publish',
+ } );
+ expect( res.status ).toBe( 200 );
+ expect( creates( getLog() ) ).toHaveLength( 0 );
+ expect( getMeta( id )[ META.error ] ).toContain( REQUIRED_MESSAGE );
+ } );
+ }
+
+ test( 'AC-016: posts already sent to beehiiv keep their template and are not blocked', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const id = createPost( 'QA E2E ptsel AC-016', {
+ status: 'publish',
+ meta: {
+ // Template first: once linked and sent, the template meta is locked.
+ [ META.template ]: OTHER_TEMPLATE,
+ [ META.postId ]: LINKED_ID,
+ [ META.send ]: '',
+ },
+ } );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, {
+ title: 'QA E2E ptsel AC-016 (edited)',
+ } );
+
+ const meta = getMeta( id );
+ expect( meta[ META.error ] ).toBeUndefined();
+ expect( meta[ META.template ] ).toBe( OTHER_TEMPLATE );
+ expect( getLog().some( ( r ) => r.method === 'PATCH' ) ).toBe( true );
+ } );
+
+ test( 'AC-017: once an editor chooses a template the send continues, and an unknown template is still rejected', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: true } );
+ const chosen = createPost( 'QA E2E ptsel AC-017 chosen', {
+ meta: { [ META.template ]: OTHER_TEMPLATE },
+ } );
+ const unknown = createPost( 'QA E2E ptsel AC-017 unknown', {
+ meta: { [ META.template ]: 'tpl_not_in_beehiiv' },
+ } );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ chosen }`, {
+ status: 'publish',
+ } );
+ const sent = creates( getLog() );
+ expect( sent ).toHaveLength( 1 );
+ expect( sentTemplate( sent[ 0 ] ) ).toBe( OTHER_TEMPLATE );
+ expect( getMeta( chosen )[ META.postId ] ).toBe( MOCK_CREATED_ID );
+
+ // Existing send-time validation still applies to the chosen template.
+ wpCli( `eval 'beehiiv_e2e_clear_http_log();'` );
+ await rest( 'POST', `/wp/v2/posts/${ unknown }`, {
+ status: 'publish',
+ } );
+ expect( creates( getLog() ) ).toHaveLength( 0 );
+ const meta = getMeta( unknown );
+ expect( meta[ META.postId ] ).toBeUndefined();
+ expect( meta[ META.error ] ).toBeTruthy();
+ } );
+
+ test( 'AC-018: with the setting off, sending behaves as before (default used, no block)', async ( {
+ page,
+ } ) => {
+ seed( { requireTemplate: false } );
+ const id = createPost( 'QA E2E ptsel AC-018' );
+ const rest = await restAs( page );
+
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+
+ const sent = creates( getLog() );
+ expect( sent ).toHaveLength( 1 );
+ expect( sentTemplate( sent[ 0 ] ) ).toBe( DEFAULT_TEMPLATE );
+ const meta = getMeta( id );
+ expect( meta[ META.error ] ).toBeUndefined();
+ expect( meta[ META.postId ] ).toBe( MOCK_CREATED_ID );
+ } );
+} );
diff --git a/tests/e2e/specs/publication-template-config.spec.js b/tests/e2e/specs/publication-template-config.spec.js
index 65a7989..1a6d01e 100644
--- a/tests/e2e/specs/publication-template-config.spec.js
+++ b/tests/e2e/specs/publication-template-config.spec.js
@@ -72,6 +72,7 @@ const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv';
const PUB_SELECT = '#beehiiv_publication_id';
const TPL_SELECT = '#beehiiv_post_template_id';
const REFRESH_BTN = '#beehiiv_refresh_post_templates';
+const REQUIRE_TOGGLE = '#beehiiv_require_template_choice';
/**
* Whether a response is the post-templates REST call containing `needle`.
@@ -523,7 +524,7 @@ test.describe( 'Publication and Template Configuration', () => {
await page.locator( '#submit' ).click();
await page.waitForURL( /page=beehiiv/ );
- expect( readSettingsOption() ).toEqual( {
+ expect( readSettingsOption() ).toMatchObject( {
publication_id: pubId,
post_template_id: tplId,
} );
@@ -578,4 +579,131 @@ test.describe( 'Publication and Template Configuration', () => {
'qa-e2e-tpl-ac013-new',
] );
} );
+
+ test( 'AC-014: settings form includes a toggle labeled "Require editors to choose a post template"', async ( {
+ page,
+ } ) => {
+ seedConnectedAndAuthorized();
+ seedPublications( [ { id: 'qa-e2e-pub-ac014', name: 'QA Pub 14' } ] );
+ setSettingsOption( '', '' );
+
+ await page.goto( SETTINGS_PATH );
+
+ const toggle = page.locator( REQUIRE_TOGGLE );
+ await expect( toggle ).toBeVisible();
+ await expect( toggle ).toHaveAttribute( 'type', 'checkbox' );
+ await expect(
+ page.getByLabel( 'Require editors to choose a post template' )
+ ).toHaveCount( 1 );
+ } );
+
+ test( 'AC-015: toggle is off by default for a site that never saved the setting', async ( {
+ page,
+ } ) => {
+ seedConnectedAndAuthorized();
+ seedPublications( [ { id: 'qa-e2e-pub-ac015', name: 'QA Pub 15' } ] );
+ wpCliSafe( `option delete ${ SETTINGS_OPTION }` );
+
+ await page.goto( SETTINGS_PATH );
+
+ await expect( page.locator( REQUIRE_TOGGLE ) ).not.toBeChecked();
+ } );
+
+ test( 'AC-016: saving stores the toggle state and it persists across sessions', async ( {
+ page,
+ browser,
+ } ) => {
+ seedConnectedAndAuthorized();
+ const pubId = 'qa-e2e-pub-ac016';
+ seedPublications( [ { id: pubId, name: 'QA Pub 16' } ] );
+ setSettingsOption( pubId, '' );
+
+ await page.goto( SETTINGS_PATH );
+ await page.locator( REQUIRE_TOGGLE ).check();
+ await page.locator( '#submit' ).click();
+ await page.waitForURL( /page=beehiiv/ );
+
+ expect( readSettingsOption().require_template_choice ).toBe( true );
+
+ // A brand-new browser context (fresh login) still sees it checked.
+ const context = await browser.newContext();
+ const fresh = await context.newPage();
+ await loginAsAdmin( fresh );
+ await fresh.goto( SETTINGS_PATH );
+ await expect( fresh.locator( REQUIRE_TOGGLE ) ).toBeChecked();
+ await context.close();
+
+ // Turning it off and saving stores false.
+ await page.goto( SETTINGS_PATH );
+ await page.locator( REQUIRE_TOGGLE ).uncheck();
+ await page.locator( '#submit' ).click();
+ await page.waitForURL( /page=beehiiv/ );
+ expect( readSettingsOption().require_template_choice ).toBe( false );
+ await expect( page.locator( REQUIRE_TOGGLE ) ).not.toBeChecked();
+ } );
+
+ test( 'AC-017: help text under the toggle explains the required-template behavior', async ( {
+ page,
+ } ) => {
+ seedConnectedAndAuthorized();
+ seedPublications( [ { id: 'qa-e2e-pub-ac017', name: 'QA Pub 17' } ] );
+ setSettingsOption( '', '' );
+
+ await page.goto( SETTINGS_PATH );
+
+ const help = page
+ .locator( 'td' )
+ .filter( { has: page.locator( REQUIRE_TOGGLE ) } )
+ .locator( 'p.description' );
+ await expect( help ).toBeVisible();
+ await expect( help ).toContainText( 'editors must pick a template' );
+ await expect( help ).toContainText(
+ 'default template is not applied automatically'
+ );
+ await expect( help ).toContainText(
+ 'sending is blocked until a template is chosen'
+ );
+ } );
+
+ test( 'AC-018: toggle is hidden for users on plans that cannot write posts', async ( {
+ page,
+ } ) => {
+ wpCli(
+ 'eval \'beehiiv_e2e_seed_connection(); beehiiv_e2e_mock_permissions( [ "posts" => [ "read" ] ] );\''
+ );
+ setSettingsOption( '', '' );
+
+ await page.goto( SETTINGS_PATH );
+
+ await expect( page.locator( PUB_SELECT ) ).toHaveCount( 0 );
+ await expect( page.locator( REQUIRE_TOGGLE ) ).toHaveCount( 0 );
+ } );
+
+ test( 'AC-019: changing the toggle does not change publication or default template selection', async ( {
+ page,
+ } ) => {
+ seedConnectedAndAuthorized();
+ const pubId = 'qa-e2e-pub-ac019';
+ const tplId = 'qa-e2e-tpl-ac019';
+ seedPublications( [ { id: pubId, name: 'QA Publication AC019' } ] );
+ seedTemplates( pubId, [ { id: tplId, name: 'QA Template AC019' } ] );
+ setSettingsOption( pubId, tplId );
+
+ await page.goto( SETTINGS_PATH );
+ await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId );
+ await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId );
+
+ await page.locator( REQUIRE_TOGGLE ).check();
+ await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId );
+ await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId );
+
+ await page.locator( '#submit' ).click();
+ await page.waitForURL( /page=beehiiv/ );
+
+ expect( readSettingsOption() ).toEqual( {
+ publication_id: pubId,
+ post_template_id: tplId,
+ require_template_choice: true,
+ } );
+ } );
} );
diff --git a/tests/e2e/specs/settings-storage-retrieval.spec.js b/tests/e2e/specs/settings-storage-retrieval.spec.js
index 4138737..df7a3be 100644
--- a/tests/e2e/specs/settings-storage-retrieval.spec.js
+++ b/tests/e2e/specs/settings-storage-retrieval.spec.js
@@ -45,6 +45,8 @@ const EXPECTED_KEYS = [
// Added by PRD-06.8.02 (per-post publication selector).
'defaultPublicationId',
'publications',
+ // Added by the required-template setting (Publication and Template Configuration).
+ 'requireTemplateChoice',
].sort();
let fixturePostId;
diff --git a/tests/e2e/specs/settings-validation-cache.spec.js b/tests/e2e/specs/settings-validation-cache.spec.js
index d20c35e..72a6430 100644
--- a/tests/e2e/specs/settings-validation-cache.spec.js
+++ b/tests/e2e/specs/settings-validation-cache.spec.js
@@ -206,6 +206,8 @@ test.describe( 'Settings validation & cache management', () => {
expect( baseline ).toEqual( {
publication_id: 'qa-e2e-pub-baseline',
post_template_id: 'qa-e2e-tmpl-baseline',
+ // The required-template setting is off unless its box is checked.
+ require_template_choice: false,
} );
// Submit `beehiiv_settings` as a flat (non-bracketed) field, so PHP
diff --git a/tests/phpunit/AdminOptionsTest.php b/tests/phpunit/AdminOptionsTest.php
new file mode 100644
index 0000000..8b46f65
--- /dev/null
+++ b/tests/phpunit/AdminOptionsTest.php
@@ -0,0 +1,147 @@
+assertFalse( Options::get()['require_template_choice'] );
+ }
+
+ /**
+ * AC-015: a saved option without the new key still reads as off.
+ */
+ public function test_saved_option_without_key_reads_as_off(): void {
+ update_option(
+ Config::OPTION_NAME,
+ array(
+ 'publication_id' => 'pub_1',
+ 'post_template_id' => 'tpl_1',
+ )
+ );
+
+ $this->assertFalse( Options::get()['require_template_choice'] );
+ }
+
+ /**
+ * AC-016: checking the box saves the setting as on.
+ */
+ public function test_sanitize_saves_checked_box_as_on(): void {
+ $saved = Options::sanitize(
+ array(
+ 'publication_id' => 'pub_1',
+ 'post_template_id' => 'tpl_1',
+ 'require_template_choice' => '1',
+ )
+ );
+
+ $this->assertTrue( $saved['require_template_choice'] );
+ }
+
+ /**
+ * AC-016: an unchecked box (hidden 0, or missing) saves as off.
+ */
+ public function test_sanitize_saves_unchecked_box_as_off(): void {
+ $with_zero = Options::sanitize(
+ array(
+ 'publication_id' => 'pub_1',
+ 'require_template_choice' => '0',
+ )
+ );
+ $missing = Options::sanitize( array( 'publication_id' => 'pub_1' ) );
+
+ $this->assertFalse( $with_zero['require_template_choice'] );
+ $this->assertFalse( $missing['require_template_choice'] );
+ }
+
+ /**
+ * AC-016: the saved value persists through the option.
+ */
+ public function test_saved_value_persists(): void {
+ update_option(
+ Config::OPTION_NAME,
+ Options::sanitize( array( 'require_template_choice' => '1' ) )
+ );
+
+ $this->assertTrue( Options::get()['require_template_choice'] );
+ }
+
+ /**
+ * AC-019: toggling the setting leaves publication and template untouched.
+ */
+ public function test_toggle_does_not_change_other_selections(): void {
+ $on = Options::sanitize(
+ array(
+ 'publication_id' => 'pub_1',
+ 'post_template_id' => 'tpl_1',
+ 'require_template_choice' => '1',
+ )
+ );
+ $off = Options::sanitize(
+ array(
+ 'publication_id' => 'pub_1',
+ 'post_template_id' => 'tpl_1',
+ 'require_template_choice' => '0',
+ )
+ );
+
+ $this->assertSame( 'pub_1', $on['publication_id'] );
+ $this->assertSame( 'tpl_1', $on['post_template_id'] );
+ $this->assertSame( 'pub_1', $off['publication_id'] );
+ $this->assertSame( 'tpl_1', $off['post_template_id'] );
+ }
+
+ /**
+ * AC-014, AC-017: the field shows the toggle label and the help text.
+ */
+ public function test_field_renders_label_and_help_text(): void {
+ ob_start();
+ Registrar::render_require_template_choice_field();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'Require editors to choose a post template', $html );
+ $this->assertStringContainsString( 'editors must pick a template for each new post', $html );
+ $this->assertStringContainsString( 'default template is not applied automatically', $html );
+ $this->assertStringContainsString( 'sending is blocked until a template is chosen', $html );
+ $this->assertStringNotContainsString( 'checked', $html );
+ }
+
+ /**
+ * AC-016: a saved on state shows the box checked.
+ */
+ public function test_field_renders_checked_when_on(): void {
+ update_option(
+ Config::OPTION_NAME,
+ Options::sanitize( array( 'require_template_choice' => '1' ) )
+ );
+
+ ob_start();
+ Registrar::render_require_template_choice_field();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( "checked='checked'", $html );
+ }
+}
diff --git a/tests/phpunit/EditorPostSettingsTest.php b/tests/phpunit/EditorPostSettingsTest.php
index ece00f1..f2a4017 100644
--- a/tests/phpunit/EditorPostSettingsTest.php
+++ b/tests/phpunit/EditorPostSettingsTest.php
@@ -10,6 +10,7 @@
* @package beehiiv
*/
+use Beehiiv\Config;
use Beehiiv\Editor\Meta;
use Beehiiv\Editor\PostSettings;
@@ -322,4 +323,21 @@ public function test_editor_save_with_readonly_meta_is_not_rejected(): void {
$this->assertSame( 'pub_qa', get_post_meta( $post_id, Meta::BEEHIIV_PUBLICATION_ID, true ) );
$this->assertSame( '', get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) );
}
+
+ /**
+ * Newsletter Template Selection PRD AC-014, AC-019: the editor learns whether
+ * the required-template setting is on, and reads off when never saved.
+ */
+ public function test_editor_config_exposes_require_template_choice(): void {
+ $method = new ReflectionMethod( PostSettings::class, 'get_editor_config' );
+ $method->setAccessible( true );
+
+ delete_option( Config::OPTION_NAME );
+ $this->assertFalse( $method->invoke( null )['requireTemplateChoice'] );
+
+ update_option( Config::OPTION_NAME, array( 'require_template_choice' => true ) );
+ $this->assertTrue( $method->invoke( null )['requireTemplateChoice'] );
+
+ delete_option( Config::OPTION_NAME );
+ }
}
diff --git a/tests/phpunit/NewsletterPostSettingsBuilderTest.php b/tests/phpunit/NewsletterPostSettingsBuilderTest.php
index 5298671..0673e0f 100644
--- a/tests/phpunit/NewsletterPostSettingsBuilderTest.php
+++ b/tests/phpunit/NewsletterPostSettingsBuilderTest.php
@@ -8,9 +8,11 @@
* @package beehiiv
*/
+use Beehiiv\API\Cache;
use Beehiiv\Config;
use Beehiiv\Editor\Meta;
use Beehiiv\Newsletter\PostSettingsBuilder;
+use Beehiiv\Newsletter\Sender;
/**
* Payload coverage for newsletter title and subtitle.
@@ -217,4 +219,109 @@ public function test_byline_stays_hidden_in_email(): void {
$settings = PostSettingsBuilder::get_post_settings( $post_id );
$this->assertFalse( $settings['email_settings']['display_byline_in_email'] );
}
+
+ /**
+ * Turn the required-template setting on, keeping the site default template.
+ */
+ private function require_template_choice(): void {
+ update_option(
+ Config::OPTION_NAME,
+ array(
+ 'publication_id' => 'pub_test',
+ 'post_template_id' => 'tmpl_test',
+ 'require_template_choice' => true,
+ )
+ );
+ }
+
+ /**
+ * Post Template Selection PRD AC-012, AC-013: with the rule on, a never-sent post
+ * gets no automatic template and cannot be sent, even with a site default set.
+ */
+ public function test_required_choice_blocks_never_sent_post_without_template(): void {
+ $this->require_template_choice();
+ $post_id = $this->create_sendable_post();
+
+ $this->assertSame( '', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) );
+
+ $settings = PostSettingsBuilder::get_post_settings( $post_id );
+
+ $this->assertWPError( $settings );
+ $this->assertSame( 'beehiiv_post_template_choice_required', $settings->get_error_code() );
+ }
+
+ /**
+ * AC-014: the blocked send shows the agreed message.
+ */
+ public function test_required_choice_error_has_agreed_message(): void {
+ $this->require_template_choice();
+ $settings = PostSettingsBuilder::get_post_settings( $this->create_sendable_post() );
+
+ $this->assertSame(
+ 'Choose a post template before sending this post to beehiiv.',
+ Sender::format_save_error_message( $settings )
+ );
+ }
+
+ /**
+ * AC-015: no user role skips the rule.
+ */
+ public function test_required_choice_applies_to_every_role(): void {
+ $this->require_template_choice();
+ $post_id = $this->create_sendable_post();
+
+ foreach ( array( 'administrator', 'editor', 'author' ) as $role ) {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) );
+
+ $this->assertWPError( PostSettingsBuilder::get_post_settings( $post_id ), $role );
+ }
+ }
+
+ /**
+ * AC-016, AC-009: a post already sent to beehiiv keeps using the default.
+ */
+ public function test_required_choice_does_not_block_sent_post(): void {
+ $this->require_template_choice();
+ $post_id = $this->create_sendable_post();
+ update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, 'post_existing' );
+
+ $this->assertSame( 'tmpl_test', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) );
+ $this->assertIsArray( PostSettingsBuilder::get_post_settings( $post_id, true ) );
+ }
+
+ /**
+ * AC-017: once an editor chooses a template, the send continues with it.
+ */
+ public function test_required_choice_passes_with_chosen_template(): void {
+ $this->require_template_choice();
+ Cache::set_post_templates(
+ 'pub_test',
+ array(
+ array(
+ 'id' => 'tmpl_chosen',
+ 'name' => 'Chosen',
+ ),
+ )
+ );
+ $post_id = $this->create_sendable_post();
+ update_post_meta( $post_id, Meta::BEEHIIV_POST_TEMPLATE_ID, 'tmpl_chosen' );
+
+ $settings = PostSettingsBuilder::get_post_settings( $post_id );
+
+ $this->assertIsArray( $settings );
+ $this->assertSame( 'tmpl_chosen', $settings['post_template_id'] );
+
+ Cache::flush_all();
+ }
+
+ /**
+ * AC-018, AC-009: with the setting off, the site default is used as today.
+ */
+ public function test_setting_off_keeps_site_default(): void {
+ $post_id = $this->create_sendable_post();
+
+ $this->assertFalse( PostSettingsBuilder::is_template_choice_required( $post_id ) );
+ $this->assertSame( 'tmpl_test', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) );
+ $this->assertIsArray( PostSettingsBuilder::get_post_settings( $post_id ) );
+ }
}