403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/api/tests/unit/test_article_creation_resilience.py
"""
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

Youez - 2016 - github.com/yon3zu
LinuXploit