# addons/itsulu_blog_publisher/tests/test_blog_post_social.py """ Tests for: - SEO field population on blog.post after generation - models/blog_post_social.py (itsulu.blog.post.social) - Notification email content and recipients RED PHASE — all tests FAIL until models and email logic exist. """ from unittest.mock import patch, MagicMock from odoo.tests import TransactionCase, tagged from .factories import BlogPublisherFactory @tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'seo') class TestSEOPopulation(TransactionCase): """Verify that all SEO fields are correctly populated after blog post generation.""" @classmethod def setUpClass(cls): super().setUpClass() cls.factory = BlogPublisherFactory(cls.env) cls.blog = cls.factory.blog(name='ITSulu Insights') def test_generated_post_has_non_empty_meta_title(self): """ 1. Behaviour: website_meta_title is set after generation 2. ARRANGE: a blog post with SEO fields populated by factory 3. ACT: check field 4. ASSERT: non-empty, ≤ 60 chars 5. FAIL reason: field population logic does not exist """ # ARRANGE post = self.factory.blog_post( blog=self.blog, website_meta_title='AI Governance Frameworks for Enterprises 2026', ) # ASSERT self.assertTrue(post.website_meta_title, "website_meta_title must not be empty after generation") self.assertLessEqual( len(post.website_meta_title), 60, f"SEO title too long: {len(post.website_meta_title)} chars (max 60)" ) def test_generated_post_has_non_empty_meta_description(self): """website_meta_description is set and ≤ 155 characters.""" # ARRANGE desc = 'Learn how leading enterprises implement AI governance frameworks in 2026.' post = self.factory.blog_post( blog=self.blog, website_meta_description=desc, ) # ASSERT self.assertTrue(post.website_meta_description) self.assertLessEqual(len(post.website_meta_description), 155) def test_generated_post_has_meta_keywords(self): """website_meta_keywords is populated with comma-separated keywords.""" # ARRANGE post = self.factory.blog_post( blog=self.blog, website_meta_keywords='AI governance, enterprise AI, compliance', ) # ASSERT self.assertTrue(post.website_meta_keywords) keywords = [k.strip() for k in post.website_meta_keywords.split(',')] self.assertGreaterEqual(len(keywords), 2, "At least 2 keywords must be present") def test_generated_post_has_at_least_two_tags(self): """blog.post.tag_ids must contain at least 2 blog.tag records.""" # ARRANGE tag1 = self.factory.blog_tag(name='Enterprise AI') tag2 = self.factory.blog_tag(name='AI Governance') post = self.factory.blog_post(blog=self.blog) post.write({'tag_ids': [(6, 0, [tag1.id, tag2.id])]}) # ASSERT self.assertGreaterEqual(len(post.tag_ids), 2) def test_seo_title_is_not_identical_to_post_title(self): """ The SEO meta title should be an optimised variant, not a verbatim copy of the post title (which would waste the SEO field). """ # ARRANGE post = self.factory.blog_post( blog=self.blog, name='AI Trends 2026', website_meta_title='Top AI Governance Trends Reshaping Enterprises in 2026', ) # ASSERT self.assertNotEqual( post.name, post.website_meta_title, "SEO meta title must be an optimised variant, not identical to the post title" ) def test_new_tags_from_llm_are_created_automatically(self): """ If the LLM returns a tag name that doesn't exist in blog.tag, the tag is created automatically and linked to the post. """ # ARRANGE — ensure tag doesn't exist new_tag_name = 'BrandNewTagXYZ99' self.assertFalse( self.env['blog.tag'].search([('name', '=', new_tag_name)]), "Pre-condition: tag must not exist before this test" ) # ACT — call the helper that creates/fetches tags by name tags = self.env['blog.tag'].get_or_create_tags([new_tag_name]) # ASSERT self.assertTrue(tags, "get_or_create_tags() must return a non-empty recordset") created = self.env['blog.tag'].search([('name', '=', new_tag_name)]) self.assertTrue(created, "The new tag must exist in the DB after get_or_create_tags()") @tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'social') class TestBlogPostSocialModel(TransactionCase): """Verify the itsulu.blog.post.social model stores all platform copy correctly.""" @classmethod def setUpClass(cls): super().setUpClass() cls.factory = BlogPublisherFactory(cls.env) cls.blog = cls.factory.blog(name='ITSulu Insights') def test_social_record_is_linked_one_to_one_with_blog_post(self): """Each blog post has at most one social record.""" # ARRANGE post = self.factory.blog_post(blog=self.blog) # ACT social = self.factory.blog_post_social(blog_post=post) # ASSERT self.assertEqual(social.blog_post_id.id, post.id) def test_twitter_posts_stored_and_retrievable(self): """twitter_post_a and twitter_post_b are stored correctly.""" # ARRANGE post = self.factory.blog_post(blog=self.blog) social = self.factory.blog_post_social( blog_post=post, twitter_post_a='80% of enterprises manage prompts manually. Read why: https://itsulu.com/blog/1', twitter_post_b='Manual prompt engineering = technical debt. Fix it: https://itsulu.com/blog/1', ) # ASSERT self.assertIn('80%', social.twitter_post_a) self.assertIn('technical debt', social.twitter_post_b) def test_twitter_posts_are_within_character_limit(self): """X/Twitter posts (excluding URL) must be ≤ 280 chars.""" # ARRANGE post = self.factory.blog_post(blog=self.blog) social = self.factory.blog_post_social(blog_post=post) # ACT — strip the URL for character count url = 'https://itsulu.com/blog/the-itsulu-blog-1/99' post_a_without_url = social.twitter_post_a.replace(url, '').strip() post_b_without_url = social.twitter_post_b.replace(url, '').strip() # ASSERT self.assertLessEqual(len(post_a_without_url), 280, "Twitter Post A exceeds 280 character limit") self.assertLessEqual(len(post_b_without_url), 280, "Twitter Post B exceeds 280 character limit") def test_mastodon_post_is_within_character_limit(self): """Mastodon/Fediverse post must be ≤ 500 characters.""" post = self.factory.blog_post(blog=self.blog) social = self.factory.blog_post_social(blog_post=post) self.assertLessEqual(len(social.mastodon_post), 500) def test_linkedin_post_is_at_least_150_characters(self): """LinkedIn copy should be substantive — minimum 150 characters.""" post = self.factory.blog_post(blog=self.blog) social = self.factory.blog_post_social( blog_post=post, linkedin_post=( 'The era of manual prompt engineering is ending. ' 'Enterprises with governance frameworks report 34% higher AI satisfaction ' 'and 76% fewer hallucinations. Learn the 3 critical shifts.' ), ) self.assertGreaterEqual(len(social.linkedin_post), 150) @tagged('post_install', '-at_install', 'itsulu_blog_publisher', 'notification_email') class TestNotificationEmail(TransactionCase): """ Verify the post-generation notification email is sent with the correct structure matching the [ITSulu Insights] template in the uploaded .eml. """ @classmethod def setUpClass(cls): super().setUpClass() cls.factory = BlogPublisherFactory(cls.env) cls.blog = cls.factory.blog(name='ITSulu Insights') cls.env['ir.config_parameter'].sudo().set_param( 'itsulu_blog_publisher.notification_emails', 'nicholasr@itsulu.com', ) def test_notification_email_is_sent_after_auto_publish(self): """ 1. Behaviour: one email sent to the configured recipient when post is published 2. ARRANGE: published post + social copy 3. ACT: call send_notification_email() on the log or the schedule model 4. ASSERT: mail.mail (or mail.message) created for the recipient 5. FAIL reason: send_notification_email() does not exist """ # ARRANGE post = self.factory.blog_post( blog=self.blog, name='Prompt Governance & AI Scaling', website_published=True, is_published=True, ) social = self.factory.blog_post_social(blog_post=post) log = self.factory.generation_log(blog_post=post, state='success') mail_count_before = self.env['mail.mail'].search_count([]) # ACT log.send_notification_email() # ASSERT mail_count_after = self.env['mail.mail'].search_count([]) self.assertEqual(mail_count_after, mail_count_before + 1, "Exactly one mail.mail record must be created") sent_mail = self.env['mail.mail'].search([], order='id desc', limit=1) self.assertIn('nicholasr@itsulu.com', sent_mail.email_to or sent_mail.recipient_ids.mapped('email')) def test_notification_email_subject_matches_expected_format(self): """Email subject: '[ITSulu Insights] Blog Post Published: {title}'""" # ARRANGE post = self.factory.blog_post( blog=self.blog, name='Prompt Governance & AI Scaling', is_published=True, ) self.factory.blog_post_social(blog_post=post) log = self.factory.generation_log(blog_post=post, state='success') # ACT — render the template synchronously to check the subject template = self.env.ref('itsulu_blog_publisher.email_template_blog_published') rendered = template.generate_email(log.id, fields=['subject']) # ASSERT subject = rendered.get('subject', '') self.assertTrue(subject, "Rendered email subject must be non-empty") self.assertIn('[ITSulu Insights] Blog Post Published:', subject, f"Unexpected subject: {subject}") self.assertIn('Prompt Governance', subject, f"Subject must contain post title, got: {subject}") def test_notification_email_body_contains_all_social_platforms(self): """Email body must contain sections for X, BlueSky, Mastodon, and LinkedIn.""" # ARRANGE post = self.factory.blog_post( blog=self.blog, name='AI Trends', is_published=True ) social = self.factory.blog_post_social( blog_post=post, twitter_post_a='Twitter A copy', twitter_post_b='Twitter B copy', bluesky_post_a='BlueSky A copy', bluesky_post_b='BlueSky B copy', mastodon_post='Mastodon copy', linkedin_post='LinkedIn copy about AI trends in enterprise.', ) log = self.factory.generation_log(blog_post=post, state='success') # ACT log.send_notification_email() # ASSERT — render template manually to verify social copy inclusion # (body_html is async-rendered, so we check the template context instead) template = self.env.ref( 'itsulu_blog_publisher.email_template_blog_published', raise_if_not_found=False, ) self.assertTrue(template, "Email template must exist") # Verify social platforms are enabled and have content self.assertTrue(social.twitter_enabled, "Twitter should be enabled") self.assertTrue(social.bluesky_enabled, "BlueSky should be enabled") self.assertTrue(social.mastodon_enabled, "Mastodon should be enabled") self.assertTrue(social.linkedin_enabled, "LinkedIn should be enabled") # Verify template renders all platform copy in the body template = self.env.ref('itsulu_blog_publisher.email_template_blog_published') rendered = template.generate_email(log.id, fields=['body_html']) body = rendered.get('body_html', '') self.assertIn('Twitter A copy', body, "Body must contain Twitter copy") self.assertIn('LinkedIn copy', body, "Body must contain LinkedIn copy") def test_notification_email_body_contains_post_url(self): """Email body must include a clickable link to the published post.""" # ARRANGE post = self.factory.blog_post( blog=self.blog, name='Cloud Migration Strategies', is_published=True ) social = self.factory.blog_post_social(blog_post=post) log = self.factory.generation_log(blog_post=post, state='success') # ACT log.send_notification_email() # ASSERT — render the template synchronously to check the URL appears in the body template = self.env.ref('itsulu_blog_publisher.email_template_blog_published') rendered = template.generate_email(log.id, fields=['body_html']) body = rendered.get('body_html', '') self.assertIn('itsulu.com', body, "Body must contain itsulu.com URL") def test_notification_email_is_not_sent_for_draft_posts(self): """No email is sent when the post is left as a draft (is_published=False).""" # ARRANGE post = self.factory.blog_post( blog=self.blog, name='Draft Post', is_published=False ) self.factory.blog_post_social(blog_post=post) log = self.factory.generation_log(blog_post=post, state='success') mail_count_before = self.env['mail.mail'].search_count([]) # ACT log.send_notification_email() # ASSERT mail_count_after = self.env['mail.mail'].search_count([]) self.assertEqual(mail_count_after, mail_count_before, "No email must be sent for a draft (unpublished) post")