"""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()