mirror of
https://gitlab.com/itsulu-odoo/itsulu-blog-publisher.git
synced 2026-05-30 23:41:23 +00:00
- views/blog_schedule_views.xml: web_ribbon invisible="active" (Odoo 17
bare-expr syntax) -> attrs="{'invisible': [('active','=',True)]}".
This was the view-validation error blocking module install on Odoo 14.
- tests: Odoo 14 TransactionCase exposes self.env in setUp(), not cls.env
in setUpClass() (that pattern is Odoo 15+). Converted all 13 setUpClass
blocks across 6 test files to setUp(self) + self.env/self.factory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
14 KiB
Python
340 lines
14 KiB
Python
# 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."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.factory = BlogPublisherFactory(self.env)
|
|
self.blog = self.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."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.factory = BlogPublisherFactory(self.env)
|
|
self.blog = self.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.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.factory = BlogPublisherFactory(self.env)
|
|
self.blog = self.factory.blog(name='ITSulu Insights')
|
|
self.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._render_field('subject', [log.id])
|
|
|
|
# ASSERT — _render_field returns {res_id: rendered_string}
|
|
subject = rendered[log.id] or ''
|
|
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._render_field('body_html', [log.id])
|
|
body = rendered[log.id] or ''
|
|
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._render_field('body_html', [log.id])
|
|
body = rendered[log.id] or ''
|
|
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")
|