467 lines
17 KiB
Python
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()
|