| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/repo/api/tests/unit/ |
Upload File : |
"""
Resilience tests for ArticleCreationService.create_article.
These lock in the behavior that a transient image-generation failure (featured
or secondary) must NOT abort the article pipeline: the post is already published
by that point, and downstream fact-checking still needs to run. Prior to this,
an uncaught HTTPException from the image step bubbled up and skipped fact-check
entirely (see the 2026-06-08 Hawk article incident).
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from app.services.article_creation_service import ArticleCreationService
from app.appTypes import (
ArticleResponse,
Character,
LLModel,
LLMProvider,
)
def _make_character(**overrides):
base = dict(
slug="hawk",
name="Hawk",
email="hawk@example.test",
description="A news anchor.",
enabled=True,
date="2026-06-08",
wp_user=None, # skips the recent-articles fetch branch
model=LLModel(provider=LLMProvider.OPEN_AI, name="gpt-5.5"),
enableFactChecking=True,
)
base.update(overrides)
return Character(**base)
def _make_service():
"""Build a service with every external collaborator mocked to a happy path."""
service = ArticleCreationService()
# --- WordPress collaborator ---
service.wp_service = MagicMock()
service.wp_service.get_jwt_token.return_value = "token-123"
service.wp_service.get_users.return_value = []
service.wp_service.get_categories.return_value = [{"id": 1, "name": "News"}]
service.wp_service.get_tags.return_value = []
service.wp_service.get_new_article_system_message.return_value = "system message"
service.wp_service.find_category_by_name.return_value = {"id": 1, "name": "News"}
service.wp_service.post_article.return_value = {"id": 123}
# Featured/secondary image generation are awaited -> AsyncMock. Happy paths:
service.wp_service.create_image = AsyncMock(return_value=("http://img/featured.png", "b64data"))
service.wp_service.create_secondary_image = AsyncMock(return_value="http://img/secondary.png")
service.wp_service.insert_image_into_content.return_value = "<p>body with image</p>"
# --- Image service collaborator ---
service.image_service = MagicMock()
service.image_service.compress_and_save.return_value = {
"id": 55,
"source_url": "http://img/uploaded.png",
}
# --- Chat/AI collaborator ---
service.chat_service = MagicMock()
article = ArticleResponse(
title="Test headline",
body="<p>Article body.</p>",
category="News",
tags=[],
)
actual_model = LLModel(provider=LLMProvider.OPEN_AI, name="gpt-5.5")
service.chat_service.createArticle.return_value = (article, actual_model, "human msg")
service.chat_service.fact_check_article.return_value = ("fact-check html", "claude-sonnet-4-6", "Claude")
# Silence logging/markdown file I/O.
for attr in (
"_setup_article_logger",
"_log",
"_write_markdown_section",
"_write_search_results_file",
"_write_summary_section",
"_cleanup_article_logger",
):
setattr(service, attr, MagicMock())
service._setup_article_logger.return_value = "/tmp/test_article.log"
return service
def test_secondary_image_failure_still_fact_checks(mock_site_config):
"""A failing secondary-image step must not abort the article or skip fact-check."""
service = _make_service()
service.wp_service.create_secondary_image.side_effect = HTTPException(
500, "No image data received from Responses API"
)
result = asyncio.run(service.create_article(
site=mock_site_config,
character=_make_character(),
generate_featured_image=True,
generate_secondary_image=True,
research_current_events=False,
enable_fact_checking=True,
progress_callback=None,
))
# Article still completed and published.
assert result is not None
assert result.post_id == 123
# Secondary image dropped, featured image preserved.
assert result.secondary_image_url is None
assert result.featured_image_url is not None
# Fact-check still ran and was posted.
service.chat_service.fact_check_article.assert_called_once()
service.wp_service.post_comment.assert_called_once()
assert result.fact_check_posted is True
def test_featured_image_failure_still_fact_checks(mock_site_config):
"""A failing featured-image step must not abort the article or skip fact-check."""
service = _make_service()
service.wp_service.create_image.side_effect = HTTPException(
500, "No image data received from Responses API"
)
result = asyncio.run(service.create_article(
site=mock_site_config,
character=_make_character(),
generate_featured_image=True,
generate_secondary_image=True,
research_current_events=False,
enable_fact_checking=True,
progress_callback=None,
))
assert result is not None
assert result.post_id == 123
assert result.featured_image_url is None
service.chat_service.fact_check_article.assert_called_once()
service.wp_service.post_comment.assert_called_once()
assert result.fact_check_posted is True
def test_corrections_runs_after_fact_check(mock_site_config):
"""With corrections enabled, the shared core runs once, threaded under the
just-posted fact-check, without re-fetching comments from WordPress."""
service = _make_service()
# The fact-check comment id must be available to thread the corrections under.
service.wp_service.post_comment.return_value = {"id": 999}
with patch(
"app.services.corrections_service.run_corrections_core",
return_value={"body_updated": False, "model_name": "gpt-5.5", "provider_name": "OpenAI"},
) as mock_corrections:
result = asyncio.run(service.create_article(
site=mock_site_config,
character=_make_character(),
generate_featured_image=False,
generate_secondary_image=False,
research_current_events=False,
enable_fact_checking=True,
enable_corrections=True,
progress_callback=None,
))
assert result.post_id == 123
assert result.fact_check_posted is True
assert result.corrections_posted is True
# Corrections core ran exactly once, fed the in-memory fact-check text and the
# parent comment id from the fact-check post (no WP re-fetch).
mock_corrections.assert_called_once()
kwargs = mock_corrections.call_args.kwargs
assert kwargs["fact_check_text"] == "fact-check html"
assert kwargs["parent_comment_id"] == 999
# The corrections path must NOT re-fetch comments (scike holds them in moderation).
service.wp_service.get_comments.assert_not_called()
def test_corrections_skipped_when_fact_check_disabled(mock_site_config):
"""Corrections must be a no-op when fact-checking didn't run."""
service = _make_service()
with patch(
"app.services.corrections_service.run_corrections_core",
) as mock_corrections:
result = asyncio.run(service.create_article(
site=mock_site_config,
character=_make_character(),
generate_featured_image=False,
generate_secondary_image=False,
research_current_events=False,
enable_fact_checking=False,
enable_corrections=True,
progress_callback=None,
))
assert result.post_id == 123
assert result.fact_check_posted is False
assert result.corrections_posted is False
mock_corrections.assert_not_called()
service.chat_service.fact_check_article.assert_not_called()
def test_corrections_failure_does_not_abort_article(mock_site_config):
"""A failing corrections step must not abort an already-published article."""
service = _make_service()
service.wp_service.post_comment.return_value = {"id": 999}
with patch(
"app.services.corrections_service.run_corrections_core",
side_effect=RuntimeError("corrections boom"),
):
result = asyncio.run(service.create_article(
site=mock_site_config,
character=_make_character(),
generate_featured_image=False,
generate_secondary_image=False,
research_current_events=False,
enable_fact_checking=True,
enable_corrections=True,
progress_callback=None,
))
# Article still completed; fact-check posted; corrections marked not posted.
assert result.post_id == 123
assert result.fact_check_posted is True
assert result.corrections_posted is False