CheddahBot/tests/test_linkbuilding.py

467 lines
17 KiB
Python

"""Tests for link building tool."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cheddahbot.tools.linkbuilding import (
_build_directory_prompt,
_build_guest_article_prompt,
_build_social_post_prompt,
_clean_content,
_extract_keyword_from_task_name,
_fuzzy_company_match,
_lookup_company,
_slugify,
build_links,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
SAMPLE_COMPANIES_MD = """\
# Company Directory
## Chapter 2 Incorporated
- **Executive:** Kyle Johnston, Senior Engineer
- **PA Org ID:** 19517
- **Website:** https://chapter2inc.com
- **GBP:** https://maps.google.com/maps?cid=111
## Hogge Precision
- **Executive:** Danny Hogge Jr, President
- **PA Org ID:** 19411
- **Website:** https://hoggeprecision.com
- **GBP:**
## Machine Specialty & Manufacturing (MSM)
- **Executive:** Max Hutson, Vice President of Operations
- **PA Org ID:** 19418
- **Website:**
- **GBP:**
"""
SAMPLE_GUEST_ARTICLE = """\
# The Growing Demand for Precision CNC Turning in Modern Manufacturing
In today's manufacturing landscape, precision CNC turning has become an essential
capability for companies serving aerospace, medical, and defense sectors. The ability
to produce tight-tolerance components from challenging materials directly impacts
product quality and supply chain reliability.
## Why Precision Matters
Chapter 2 Incorporated has invested significantly in multi-axis CNC turning centers
that deliver tolerances within +/- 0.0005 inches. This level of precision CNC turning
capability enables the production of critical components for demanding applications.
## Industry Trends
The shift toward automation and lights-out manufacturing continues to drive investment
in advanced CNC turning equipment. Companies that can maintain tight tolerances while
increasing throughput are positioned to win new contracts.
## About the Author
Kyle Johnston is a Senior Engineer at Chapter 2 Incorporated, specializing in
precision machining solutions for aerospace and defense applications.
"""
SAMPLE_DIRECTORY_ENTRY = """\
## Company Description
Chapter 2 Incorporated is a precision CNC machining company specializing in complex
turned and milled components for aerospace, defense, and medical industries. With
state-of-the-art CNC turning capabilities, the company delivers tight-tolerance
parts from a wide range of materials including titanium, Inconel, and stainless steel.
## Services
- Precision CNC turning and multi-axis machining
- Swiss-type screw machining for small-diameter components
- CNC milling and 5-axis machining
- Prototype to production manufacturing
- Material sourcing and supply chain management
- Quality inspection and certification (AS9100, ISO 9001)
## About
Chapter 2 Incorporated was founded to serve the growing need for high-precision
machined components. The company operates out of modern facilities equipped with
the latest CNC turning and milling technology.
"""
SAMPLE_SOCIAL_POST = """\
Precision CNC turning continues to drive innovation in aerospace manufacturing.
Our latest article explores how advanced multi-axis turning centers are enabling
tighter tolerances and faster production cycles.
Read more: https://chapter2inc.com/cnc-turning
#CNCMachining #PrecisionManufacturing #AerospaceMachining
"""
@pytest.fixture()
def mock_ctx(tmp_path):
"""Create a mock tool context."""
agent = MagicMock()
agent.execute_task.return_value = SAMPLE_GUEST_ARTICLE
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
config = MagicMock()
config.clickup.enabled = False
db = MagicMock()
db.kv_set = MagicMock()
db.kv_get = MagicMock(return_value=None)
return {
"agent": agent,
"config": config,
"db": db,
}
# ---------------------------------------------------------------------------
# Helper tests
# ---------------------------------------------------------------------------
class TestExtractKeyword:
def test_links_prefix(self):
assert _extract_keyword_from_task_name("LINKS - precision cnc turning") == "precision cnc turning"
def test_links_prefix_extra_spaces(self):
assert _extract_keyword_from_task_name("LINKS - swiss type lathe machining ") == "swiss type lathe machining"
def test_no_prefix(self):
assert _extract_keyword_from_task_name("precision cnc turning") == "precision cnc turning"
def test_links_prefix_uppercase(self):
assert _extract_keyword_from_task_name("LINKS - CNC Swiss Screw Machining") == "CNC Swiss Screw Machining"
def test_multiple_dashes(self):
assert _extract_keyword_from_task_name("LINKS - high-speed beveling machine") == "high-speed beveling machine"
class TestSlugify:
def test_basic(self):
assert _slugify("precision cnc turning") == "precision-cnc-turning"
def test_special_chars(self):
assert _slugify("CNC Swiss Screw Machining!") == "cnc-swiss-screw-machining"
def test_max_length(self):
long = "a " * 50
assert len(_slugify(long)) <= 60
def test_hyphens(self):
assert _slugify("high-speed beveling machine") == "high-speed-beveling-machine"
class TestFuzzyCompanyMatch:
def test_exact_match(self):
assert _fuzzy_company_match("Chapter2", "Chapter2") is True
def test_case_insensitive(self):
assert _fuzzy_company_match("chapter2", "Chapter2") is True
def test_substring_match(self):
assert _fuzzy_company_match("Chapter 2", "Chapter 2 Incorporated") is True
def test_reverse_substring(self):
assert _fuzzy_company_match("Chapter 2 Incorporated", "Chapter 2") is True
def test_no_match(self):
assert _fuzzy_company_match("Chapter2", "Hogge Precision") is False
def test_empty(self):
assert _fuzzy_company_match("", "Chapter2") is False
assert _fuzzy_company_match("Chapter2", "") is False
class TestCleanContent:
def test_strips_preamble(self):
raw = "Here is the guest article:\n\n# Title\nContent here."
result = _clean_content(raw)
assert result.startswith("# Title")
def test_strips_trailing_separator(self):
raw = "Content here.\n---"
result = _clean_content(raw)
assert result == "Content here."
def test_strips_trailing_letmeknow(self):
raw = "Content here.\nLet me know if you need any changes."
result = _clean_content(raw)
assert result == "Content here."
def test_passthrough_clean(self):
raw = "# Title\n\nClean content."
assert _clean_content(raw) == raw
class TestLookupCompany:
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
def test_lookup_found(self, mock_file):
mock_file.exists.return_value = True
mock_file.read_text.return_value = SAMPLE_COMPANIES_MD
result = _lookup_company("Hogge Precision")
assert result["name"] == "Hogge Precision"
assert result["executive"] == "Danny Hogge Jr, President"
assert result["pa_org_id"] == "19411"
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
def test_lookup_fuzzy(self, mock_file):
mock_file.exists.return_value = True
mock_file.read_text.return_value = SAMPLE_COMPANIES_MD
result = _lookup_company("Chapter 2")
assert result["name"] == "Chapter 2 Incorporated"
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
def test_lookup_not_found(self, mock_file):
mock_file.exists.return_value = True
mock_file.read_text.return_value = SAMPLE_COMPANIES_MD
result = _lookup_company("Nonexistent Corp")
assert result["name"] == "Nonexistent Corp"
assert "executive" not in result
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
def test_lookup_no_file(self, mock_file):
mock_file.exists.return_value = False
result = _lookup_company("Chapter2")
assert result == {"name": "Chapter2"}
# ---------------------------------------------------------------------------
# Prompt builder tests
# ---------------------------------------------------------------------------
class TestPromptBuilders:
def test_guest_article_prompt_includes_keyword(self):
prompt = _build_guest_article_prompt(
"precision cnc turning", "Chapter2", "https://chapter2.com", {}, ""
)
assert "precision cnc turning" in prompt
assert "Chapter2" in prompt
assert "500-700 word" in prompt
def test_guest_article_prompt_includes_url(self):
prompt = _build_guest_article_prompt(
"cnc machining", "Hogge", "https://hogge.com", {"executive": "Danny"}, ""
)
assert "https://hogge.com" in prompt
assert "Danny" in prompt
def test_guest_article_prompt_includes_skill(self):
prompt = _build_guest_article_prompt(
"welding", "GullCo", "", {}, "Skill context here"
)
assert "Skill context here" in prompt
def test_directory_prompt_includes_fields(self):
prompt = _build_directory_prompt(
"cnc turning", "Chapter2", "https://ch2.com", "https://linkedin.com/ch2",
{"executive": "Kyle Johnston"},
)
assert "cnc turning" in prompt
assert "Chapter2" in prompt
assert "Kyle Johnston" in prompt
assert "150-200 words" in prompt
def test_social_post_prompt(self):
prompt = _build_social_post_prompt(
"cnc machining", "Hogge Precision", "https://hogge.com",
"The Future of CNC Machining",
)
assert "LinkedIn" in prompt
assert "Hogge Precision" in prompt
assert "100-150 words" in prompt
# ---------------------------------------------------------------------------
# Main tool integration tests
# ---------------------------------------------------------------------------
class TestBuildLinks:
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_generates_three_content_pieces(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
# Set up agent mocks for the three calls
agent = mock_ctx["agent"]
agent.execute_task.side_effect = [SAMPLE_GUEST_ARTICLE, SAMPLE_DIRECTORY_ENTRY]
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
result = build_links(
keyword="precision cnc turning",
company_name="Chapter2",
target_url="https://chapter2inc.com",
ctx=mock_ctx,
)
assert "Link Building Complete" in result
assert "Guest Article" in result
assert "Directory Listing" in result
assert "Social Post" in result
assert "3" in result # 3 deliverables
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_extracts_keyword_from_links_prefix(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
agent = mock_ctx["agent"]
agent.execute_task.side_effect = [SAMPLE_GUEST_ARTICLE, SAMPLE_DIRECTORY_ENTRY]
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
result = build_links(
keyword="LINKS - precision cnc turning",
company_name="Chapter2",
ctx=mock_ctx,
)
# The keyword should have been extracted, not passed as "LINKS - ..."
assert "precision cnc turning" in result
# The execute_task calls should use the extracted keyword
call_args = agent.execute_task.call_args_list
assert "precision cnc turning" in call_args[0][0][0]
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_saves_files_to_output_dir(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
agent = mock_ctx["agent"]
agent.execute_task.side_effect = [SAMPLE_GUEST_ARTICLE, SAMPLE_DIRECTORY_ENTRY]
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
build_links(
keyword="cnc turning",
company_name="TestCo",
ctx=mock_ctx,
)
# Check files were created
company_dir = tmp_path / "testco" / "cnc-turning"
assert (company_dir / "guest-article.md").exists()
assert (company_dir / "directory-listing.md").exists()
assert (company_dir / "social-post.md").exists()
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_handles_execution_failure_gracefully(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
agent = mock_ctx["agent"]
agent.execute_task.side_effect = Exception("LLM timeout")
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
result = build_links(
keyword="cnc turning",
company_name="TestCo",
ctx=mock_ctx,
)
# Should still complete with warnings instead of crashing
assert "Warnings" in result
assert "failed" in result.lower()
def test_no_agent_returns_error(self):
result = build_links(keyword="test", company_name="Test", ctx={})
assert "Error" in result
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_short_content_generates_warning(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
agent = mock_ctx["agent"]
# Return very short content
agent.execute_task.side_effect = ["Short.", "Also short."]
agent.llm.chat.return_value = iter([{"type": "text", "content": "Too brief."}])
result = build_links(
keyword="test keyword",
company_name="TestCo",
ctx=mock_ctx,
)
assert "too short" in result.lower()
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_sets_pipeline_status(self, mock_output_dir, mock_companies, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
agent = mock_ctx["agent"]
agent.execute_task.side_effect = [SAMPLE_GUEST_ARTICLE, SAMPLE_DIRECTORY_ENTRY]
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
build_links(
keyword="cnc turning",
company_name="TestCo",
ctx=mock_ctx,
)
# Should have set pipeline status multiple times
db = mock_ctx["db"]
status_calls = [c for c in db.kv_set.call_args_list if c[0][0] == "pipeline:status"]
assert len(status_calls) >= 3 # At least once per step + clear
class TestBuildLinksClickUpIntegration:
@patch("cheddahbot.tools.linkbuilding._get_clickup_client")
@patch("cheddahbot.tools.linkbuilding._COMPANIES_FILE")
@patch("cheddahbot.tools.linkbuilding._OUTPUT_DIR")
def test_clickup_sync_on_task_id(self, mock_output_dir, mock_companies,
mock_get_client, tmp_path, mock_ctx):
mock_output_dir.__truediv__ = lambda self, x: tmp_path / x
mock_companies.exists.return_value = False
# Set up ClickUp mock
cu_client = MagicMock()
cu_client.upload_attachment.return_value = True
cu_client.update_task_status.return_value = True
cu_client.add_comment.return_value = True
mock_get_client.return_value = cu_client
# Enable ClickUp in config
mock_ctx["config"].clickup.enabled = True
mock_ctx["config"].clickup.review_status = "internal review"
mock_ctx["clickup_task_id"] = "task_abc123"
agent = mock_ctx["agent"]
agent.execute_task.side_effect = [SAMPLE_GUEST_ARTICLE, SAMPLE_DIRECTORY_ENTRY]
agent.llm.chat.return_value = iter([{"type": "text", "content": SAMPLE_SOCIAL_POST}])
result = build_links(
keyword="cnc turning",
company_name="TestCo",
ctx=mock_ctx,
)
assert "ClickUp Sync" in result
cu_client.upload_attachment.assert_called()
cu_client.update_task_status.assert_called_once_with("task_abc123", "internal review")
cu_client.add_comment.assert_called_once()