{{ title }}
{{ content }} diff --git a/src/templating/templates/classic.html b/src/templating/templates/classic.html index f6853b2..d933b94 100644 --- a/src/templating/templates/classic.html +++ b/src/templating/templates/classic.html @@ -73,6 +73,38 @@ a:hover { color: #5d4a37; } + nav { + max-width: 750px; + margin: 0 auto 30px; + background: #fff; + padding: 1.25rem 2rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border: 1px solid #e0d7c9; + } + nav ul { + list-style: none; + display: flex; + justify-content: center; + gap: 2.5rem; + margin: 0; + padding: 0; + } + nav li { + margin: 0; + } + nav a { + color: #8b7355; + text-decoration: none; + font-weight: 600; + font-size: 1.05rem; + padding: 0.5rem 1rem; + border-radius: 4px; + transition: all 0.2s; + } + nav a:hover { + background-color: #f9f6f2; + color: #5d4a37; + } @media (max-width: 768px) { body { padding: 10px; @@ -92,10 +124,25 @@ p { text-indent: 0; } + nav { + padding: 1rem; + } + nav ul { + flex-wrap: wrap; + gap: 1rem; + } } +{{ title }}
{{ content }} diff --git a/src/templating/templates/minimal.html b/src/templating/templates/minimal.html index ff84145..6634340 100644 --- a/src/templating/templates/minimal.html +++ b/src/templating/templates/minimal.html @@ -60,6 +60,36 @@ a:hover { border-bottom: 2px solid #000; } + nav { + margin-bottom: 3rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #000; + } + nav ul { + list-style: none; + display: flex; + justify-content: center; + gap: 2rem; + margin: 0; + padding: 0; + } + nav li { + margin: 0; + } + nav a { + color: #000; + text-decoration: none; + font-weight: 600; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.5rem 0; + border-bottom: 2px solid transparent; + transition: border-color 0.2s; + } + nav a:hover { + border-bottom-color: #000; + } @media (max-width: 768px) { body { padding: 20px 15px; @@ -73,10 +103,22 @@ h3 { font-size: 1.2rem; } + nav ul { + flex-wrap: wrap; + gap: 1rem; + } } +{{ title }}
{{ content }} diff --git a/src/templating/templates/modern.html b/src/templating/templates/modern.html index fd230e7..114c644 100644 --- a/src/templating/templates/modern.html +++ b/src/templating/templates/modern.html @@ -80,6 +80,40 @@ color: #764ba2; text-decoration: underline; } + nav { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + max-width: 850px; + margin: 0 auto 30px; + padding: 1.5rem 2rem; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + } + nav ul { + list-style: none; + display: flex; + justify-content: center; + gap: 2.5rem; + margin: 0; + padding: 0; + } + nav li { + margin: 0; + } + nav a { + color: #667eea; + font-weight: 600; + font-size: 1.05rem; + padding: 0.5rem 1rem; + border-radius: 8px; + transition: all 0.3s ease; + } + nav a:hover { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + text-decoration: none; + transform: translateY(-2px); + } @media (max-width: 768px) { body { padding: 20px 10px; @@ -96,10 +130,25 @@ h3 { font-size: 1.3rem; } + nav { + padding: 1rem; + } + nav ul { + flex-wrap: wrap; + gap: 1rem; + } } +{{ title }}
{{ content }} diff --git a/tests/integration/test_content_injection_integration.py b/tests/integration/test_content_injection_integration.py new file mode 100644 index 0000000..f1ae366 --- /dev/null +++ b/tests/integration/test_content_injection_integration.py @@ -0,0 +1,490 @@ +""" +Integration tests for content injection +Tests full flow with database +""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from src.database.models import Base, User, Project, SiteDeployment, GeneratedContent, ArticleLink +from src.database.repositories import ( + ProjectRepository, + GeneratedContentRepository, + SiteDeploymentRepository, + ArticleLinkRepository +) +from src.interlinking.content_injection import inject_interlinks +from src.generation.url_generator import generate_urls_for_batch +from src.interlinking.tiered_links import find_tiered_links + + +@pytest.fixture +def db_session(): + """Create an in-memory SQLite database for testing""" + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + + +@pytest.fixture +def user(db_session): + """Create a test user""" + user = User( + username="testuser", + hashed_password="hashed_pwd", + role="Admin" + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def project(db_session, user): + """Create a test project""" + project = Project( + user_id=user.id, + name="Test Project", + main_keyword="shaft machining", + tier=1, + money_site_url="https://moneysite.com", + related_searches=["cnc machining", "precision machining"], + entities=["lathe", "mill", "CNC"] + ) + db_session.add(project) + db_session.commit() + db_session.refresh(project) + return project + + +@pytest.fixture +def site_deployment(db_session): + """Create a test site deployment""" + site = SiteDeployment( + site_name="Test Site", + custom_hostname="www.testsite.com", + storage_zone_id=123, + storage_zone_name="test-zone", + storage_zone_password="test-pass", + storage_zone_region="NY", + pull_zone_id=456, + pull_zone_bcdn_hostname="testsite.b-cdn.net" + ) + db_session.add(site) + db_session.commit() + db_session.refresh(site) + return site + + +@pytest.fixture +def content_repo(db_session): + return GeneratedContentRepository(db_session) + + +@pytest.fixture +def project_repo(db_session): + return ProjectRepository(db_session) + + +@pytest.fixture +def site_repo(db_session): + return SiteDeploymentRepository(db_session) + + +@pytest.fixture +def link_repo(db_session): + return ArticleLinkRepository(db_session) + + +class TestTier1ContentInjection: + """Integration tests for Tier 1 content injection""" + + def test_tier1_batch_with_money_site_links( + self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo + ): + """Test full flow: create T1 articles, inject money site links, See Also section""" + # Create 3 tier1 articles + articles = [] + for i in range(3): + content = content_repo.create( + project_id=project.id, + tier="tier1", + keyword=f"keyword_{i}", + title=f"Article {i} About Shaft Machining", + outline={"sections": ["intro", "body"]}, + content=f"This is article {i} about shaft machining and Home page. Learn about shaft machining here.
", + word_count=50, + status="generated", + site_deployment_id=site_deployment.id + ) + articles.append(content) + + # Generate URLs + article_urls = generate_urls_for_batch(articles, site_repo) + + # Find tiered links + job_config = None + tiered_links = find_tiered_links(articles, job_config, project_repo, content_repo, site_repo) + + assert tiered_links['tier'] == 1 + assert tiered_links['money_site_url'] == "https://moneysite.com" + + # Inject interlinks + inject_interlinks(articles, article_urls, tiered_links, project, job_config, content_repo, link_repo) + + # Verify each article + for i, article in enumerate(articles): + db_session.refresh(article) + + # Should have money site link + assert '' in article.content + + # Should have See Also section + assert "See Also
" in article.content + assert "- " in article.content
+
+ # Should link to other 2 articles
+ other_articles = [a for a in articles if a.id != article.id]
+ for other in other_articles:
+ assert other.title in article.content
+
+ # Check ArticleLink records
+ outbound_links = link_repo.get_by_source_article(article.id)
+
+ # Should have 1 tiered (money site) + 2 wheel_see_also links
+ assert len(outbound_links) >= 3
+
+ tiered_links_found = [l for l in outbound_links if l.link_type == "tiered"]
+ assert len(tiered_links_found) == 1
+ assert tiered_links_found[0].to_url == "https://moneysite.com"
+
+ see_also_links = [l for l in outbound_links if l.link_type == "wheel_see_also"]
+ assert len(see_also_links) == 2
+
+ def test_tier1_with_homepage_links(
+ self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo
+ ):
+ """Test homepage link injection"""
+ # Create 1 tier1 article
+ content = content_repo.create(
+ project_id=project.id,
+ tier="tier1",
+ keyword="test_keyword",
+ title="Test Article",
+ outline={"sections": []},
+ content="
Content about shaft machining and processes Home today.
", + word_count=30, + status="generated", + site_deployment_id=site_deployment.id + ) + + # Generate URL + article_urls = generate_urls_for_batch([content], site_repo) + + # Find tiered links + tiered_links = find_tiered_links([content], None, project_repo, content_repo, site_repo) + + # Inject interlinks + inject_interlinks([content], article_urls, tiered_links, project, None, content_repo, link_repo) + + db_session.refresh(content) + + # Should have homepage link with "Home" as anchor text to /index.html + assert '' in content.content + assert 'index.html">Home' in content.content + + # Check homepage link in database + outbound_links = link_repo.get_by_source_article(content.id) + homepage_links = [l for l in outbound_links if l.link_type == "homepage"] + assert len(homepage_links) >= 1 + + +class TestTier2ContentInjection: + """Integration tests for Tier 2 content injection""" + + def test_tier2_links_to_tier1( + self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo + ): + """Test T2 articles linking to T1 articles""" + # Create 5 tier1 articles + t1_articles = [] + for i in range(5): + content = content_repo.create( + project_id=project.id, + tier="tier1", + keyword=f"t1_keyword_{i}", + title=f"T1 Article {i}", + outline={"sections": []}, + content=f"T1 article {i} content about shaft machining.
", + word_count=30, + status="generated", + site_deployment_id=site_deployment.id + ) + t1_articles.append(content) + + # Create 3 tier2 articles + t2_articles = [] + for i in range(3): + content = content_repo.create( + project_id=project.id, + tier="tier2", + keyword=f"t2_keyword_{i}", + title=f"T2 Article {i}", + outline={"sections": []}, + content=f"T2 article {i} with cnc machining and precision machining content here.
", + word_count=40, + status="generated", + site_deployment_id=site_deployment.id + ) + t2_articles.append(content) + + # Generate URLs for T2 articles + article_urls = generate_urls_for_batch(t2_articles, site_repo) + + # Find tiered links for T2 + tiered_links = find_tiered_links(t2_articles, None, project_repo, content_repo, site_repo) + + assert tiered_links['tier'] == 2 + assert tiered_links['lower_tier'] == 1 + assert len(tiered_links['lower_tier_urls']) >= 2 # Should select 2-4 random T1 URLs + + # Inject interlinks + inject_interlinks(t2_articles, article_urls, tiered_links, project, None, content_repo, link_repo) + + # Verify T2 articles + for article in t2_articles: + db_session.refresh(article) + + # Should have links to T1 articles + assert '' in content.content + + # Should have homepage link (using "Home" anchor to /index.html) + assert 'index.html">Home' in content.content + + def test_large_batch( + self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo + ): + """Test batch with 20 articles""" + articles = [] + for i in range(20): + content = content_repo.create( + project_id=project.id, + tier="tier1", + keyword=f"kw_{i}", + title=f"Article {i}", + outline={}, + content=f"Article {i} about shaft machining processes.
", + word_count=30, + status="generated", + site_deployment_id=site_deployment.id + ) + articles.append(content) + + article_urls = generate_urls_for_batch(articles, site_repo) + tiered_links = find_tiered_links(articles, None, project_repo, content_repo, site_repo) + + inject_interlinks(articles, article_urls, tiered_links, project, None, content_repo, link_repo) + + # Verify first article has 19 See Also links + first_article = articles[0] + db_session.refresh(first_article) + + assert "See Also
" in first_article.content + + outbound_links = link_repo.get_by_source_article(first_article.id) + see_also_links = [l for l in outbound_links if l.link_type == "wheel_see_also"] + assert len(see_also_links) == 19 + + +class TestLinkDatabaseRecords: + """Test ArticleLink database records""" + + def test_all_link_types_recorded( + self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo + ): + """Test that all link types are properly recorded""" + articles = [] + for i in range(3): + content = content_repo.create( + project_id=project.id, + tier="tier1", + keyword=f"kw_{i}", + title=f"Article {i}", + outline={}, + content=f"Content {i} about shaft machining here.
", + word_count=30, + status="generated", + site_deployment_id=site_deployment.id + ) + articles.append(content) + + article_urls = generate_urls_for_batch(articles, site_repo) + tiered_links = find_tiered_links(articles, None, project_repo, content_repo, site_repo) + + inject_interlinks(articles, article_urls, tiered_links, project, None, content_repo, link_repo) + + # Check all link types exist + all_tiered = link_repo.get_by_link_type("tiered") + all_homepage = link_repo.get_by_link_type("homepage") + all_see_also = link_repo.get_by_link_type("wheel_see_also") + + assert len(all_tiered) >= 3 # At least 1 per article + assert len(all_see_also) >= 6 # Each article links to 2 others + + def test_internal_vs_external_links( + self, db_session, project, site_deployment, content_repo, project_repo, site_repo, link_repo + ): + """Test internal (to_content_id) vs external (to_url) links""" + # Create T1 articles + t1_articles = [] + for i in range(2): + content = content_repo.create( + project_id=project.id, + tier="tier1", + keyword=f"t1_{i}", + title=f"T1 Article {i}", + outline={}, + content=f"T1 content {i} about shaft machining.
", + word_count=30, + status="generated", + site_deployment_id=site_deployment.id + ) + t1_articles.append(content) + + article_urls = generate_urls_for_batch(t1_articles, site_repo) + tiered_links = find_tiered_links(t1_articles, None, project_repo, content_repo, site_repo) + + inject_interlinks(t1_articles, article_urls, tiered_links, project, None, content_repo, link_repo) + + # Check links for first article + outbound = link_repo.get_by_source_article(t1_articles[0].id) + + # Tiered link (to money site) should have to_url, not to_content_id + tiered = [l for l in outbound if l.link_type == "tiered"] + assert len(tiered) >= 1 + assert tiered[0].to_url is not None + assert tiered[0].to_content_id is None + + # See Also links should have to_content_id + see_also = [l for l in outbound if l.link_type == "wheel_see_also"] + for link in see_also: + assert link.to_content_id is not None + assert link.to_content_id in [a.id for a in t1_articles] + diff --git a/tests/unit/test_content_injection.py b/tests/unit/test_content_injection.py new file mode 100644 index 0000000..aebd41b --- /dev/null +++ b/tests/unit/test_content_injection.py @@ -0,0 +1,410 @@ +""" +Unit tests for content injection module +""" + +import pytest +from unittest.mock import Mock, MagicMock, patch +from src.interlinking.content_injection import ( + inject_interlinks, + _inject_tiered_links, + _inject_homepage_link, + _inject_see_also_section, + _get_anchor_texts_for_tier, + _try_inject_link, + _find_and_wrap_anchor_text, + _insert_link_into_random_paragraph, + _extract_homepage_url, + _insert_before_closing_tags +) +from src.database.models import GeneratedContent, Project + + +@pytest.fixture +def mock_project(): + """Create a mock Project""" + project = Mock(spec=Project) + project.id = 1 + project.main_keyword = "shaft machining" + project.related_searches = ["cnc shaft machining", "precision shaft machining"] + project.entities = ["lathe", "milling", "CNC"] + return project + + +@pytest.fixture +def mock_content(): + """Create a mock GeneratedContent""" + content = Mock(spec=GeneratedContent) + content.id = 1 + content.project_id = 1 + content.tier = "tier1" + content.title = "Guide to Shaft Machining" + content.content = "Shaft machining is an important process. Learn about shaft machining here.
" + return content + + +@pytest.fixture +def mock_content_repo(): + """Create a mock GeneratedContentRepository""" + repo = Mock() + repo.update = Mock(return_value=None) + return repo + + +@pytest.fixture +def mock_link_repo(): + """Create a mock ArticleLinkRepository""" + repo = Mock() + repo.create = Mock(return_value=None) + return repo + + +class TestExtractHomepageUrl: + """Tests for homepage URL extraction""" + + def test_extract_from_https_url(self): + url = "https://example.com/article-slug.html" + result = _extract_homepage_url(url) + assert result == "https://example.com/" + + def test_extract_from_http_url(self): + url = "http://example.com/article.html" + result = _extract_homepage_url(url) + assert result == "http://example.com/" + + def test_extract_from_cdn_url(self): + url = "https://site.b-cdn.net/my-article.html" + result = _extract_homepage_url(url) + assert result == "https://site.b-cdn.net/" + + def test_extract_from_custom_domain(self): + url = "https://www.custom.com/path/to/article.html" + result = _extract_homepage_url(url) + assert result == "https://www.custom.com/" + + def test_extract_with_port(self): + url = "https://example.com:8080/article.html" + result = _extract_homepage_url(url) + assert result == "https://example.com:8080/" + + +class TestInsertBeforeClosingTags: + """Tests for inserting content before closing tags""" + + def test_insert_after_last_paragraph(self): + html = "First paragraph
Last paragraph
" + content = "New Section
" + result = _insert_before_closing_tags(html, content) + assert "New Section
" in result + assert result.index("Last paragraph") < result.index("New Section
") + + def test_insert_with_body_tag(self): + html = "Content
" + content = "See Also
" + result = _insert_before_closing_tags(html, content) + assert "See Also
" in result + + def test_insert_with_no_paragraphs(self): + html = "Section
" + result = _insert_before_closing_tags(html, content) + assert "Section
" in result + + +class TestFindAndWrapAnchorText: + """Tests for finding and wrapping anchor text""" + + def test_find_exact_match(self): + html = "This is about shaft machining processes.
" + anchor = "shaft machining" + url = "https://example.com" + result, found = _find_and_wrap_anchor_text(html, anchor, url) + assert found + assert f'' in result + assert "shaft machining" in result + + def test_case_insensitive_match(self): + html = "This is about Shaft Machining processes.
" + anchor = "shaft machining" + url = "https://example.com" + result, found = _find_and_wrap_anchor_text(html, anchor, url) + assert found + assert f'' in result + + def test_match_within_phrase(self): + html = "The shaft machining process is complex.
" + anchor = "shaft machining" + url = "https://example.com" + result, found = _find_and_wrap_anchor_text(html, anchor, url) + assert found + assert f'' in result + + def test_no_match(self): + html = "This is about something else.
" + anchor = "shaft machining" + url = "https://example.com" + result, found = _find_and_wrap_anchor_text(html, anchor, url) + assert not found + assert result == html + + def test_skip_existing_links(self): + html = 'Read about shaft machining here. Also shaft machining is important.
' + anchor = "shaft machining" + url = "https://example.com" + result, found = _find_and_wrap_anchor_text(html, anchor, url) + assert found + # Should link the second occurrence, not the one already linked + assert result.count(f'') == 1 + + +class TestInsertLinkIntoRandomParagraph: + """Tests for inserting link into random paragraph""" + + def test_insert_into_paragraph(self): + html = "This is a long paragraph with many words and sentences. It has enough content.
" + anchor = "shaft machining" + url = "https://example.com" + result = _insert_link_into_random_paragraph(html, anchor, url) + assert f'{anchor}' in result + + def test_insert_with_multiple_paragraphs(self): + html = "First paragraph.
Second paragraph with more text.
Third paragraph.
" + anchor = "test link" + url = "https://example.com" + result = _insert_link_into_random_paragraph(html, anchor, url) + assert f'{anchor}' in result + + def test_no_valid_paragraphs(self): + html = "Hi
Ok
" + anchor = "test" + url = "https://example.com" + result = _insert_link_into_random_paragraph(html, anchor, url) + # Should return original HTML if no valid paragraphs + assert result == html or f'' in result + + +class TestGetAnchorTextsForTier: + """Tests for anchor text generation with job config overrides""" + + def test_default_mode(self, mock_project): + job_config = {"anchor_text_config": {"mode": "default"}} + with patch('src.interlinking.content_injection.get_anchor_text_for_tier') as mock_get: + mock_get.return_value = ["anchor1", "anchor2"] + result = _get_anchor_texts_for_tier("tier1", mock_project, job_config) + assert result == ["anchor1", "anchor2"] + + def test_override_mode(self, mock_project): + custom = ["custom anchor 1", "custom anchor 2"] + job_config = {"anchor_text_config": {"mode": "override", "custom_text": custom}} + result = _get_anchor_texts_for_tier("tier1", mock_project, job_config) + assert result == custom + + def test_append_mode(self, mock_project): + custom = ["custom anchor"] + job_config = {"anchor_text_config": {"mode": "append", "custom_text": custom}} + with patch('src.interlinking.content_injection.get_anchor_text_for_tier') as mock_get: + mock_get.return_value = ["default1", "default2"] + result = _get_anchor_texts_for_tier("tier1", mock_project, job_config) + assert result == ["default1", "default2", "custom anchor"] + + def test_no_config(self, mock_project): + job_config = None + with patch('src.interlinking.content_injection.get_anchor_text_for_tier') as mock_get: + mock_get.return_value = ["default"] + result = _get_anchor_texts_for_tier("tier1", mock_project, job_config) + assert result == ["default"] + + +class TestTryInjectLink: + """Tests for link injection attempts""" + + def test_inject_with_found_anchor(self): + html = "This is about shaft machining here.
" + anchors = ["shaft machining", "other anchor"] + url = "https://example.com" + result, injected = _try_inject_link(html, anchors, url) + assert injected + assert f'' in result + + def test_inject_with_fallback(self): + html = "This is a paragraph about something else entirely.
" + anchors = ["shaft machining"] + url = "https://example.com" + result, injected = _try_inject_link(html, anchors, url) + assert injected + assert f'' in result + + def test_no_anchors(self): + html = "Content
" + anchors = [] + url = "https://example.com" + result, injected = _try_inject_link(html, anchors, url) + assert not injected + assert result == html + + +class TestInjectSeeAlsoSection: + """Tests for See Also section injection""" + + def test_inject_see_also_with_multiple_articles(self, mock_content, mock_link_repo): + html = "Article content here.
" + article_urls = [ + {"content_id": 1, "title": "Article 1", "url": "https://example.com/article1.html"}, + {"content_id": 2, "title": "Article 2", "url": "https://example.com/article2.html"}, + {"content_id": 3, "title": "Article 3", "url": "https://example.com/article3.html"} + ] + mock_content.id = 1 + + result = _inject_see_also_section(html, mock_content, article_urls, mock_link_repo) + + assert "See Also
" in result + assert "- " in result
+ assert "Article 2" in result
+ assert "Article 3" in result
+ assert "Article 1" not in result # Current article excluded
+ assert mock_link_repo.create.call_count == 2
+
+ def test_inject_see_also_with_single_article(self, mock_content, mock_link_repo):
+ html = "
Content
" + article_urls = [ + {"content_id": 1, "title": "Only Article", "url": "https://example.com/article.html"} + ] + mock_content.id = 1 + + result = _inject_see_also_section(html, mock_content, article_urls, mock_link_repo) + + # No other articles, should return original HTML + assert result == html or "See Also
" not in result + + +class TestInjectHomepageLink: + """Tests for homepage link injection""" + + def test_inject_homepage_link(self, mock_content, mock_project, mock_link_repo): + html = "This is about content and going Home is great.
" + article_url = "https://example.com/article.html" + + result = _inject_homepage_link(html, mock_content, article_url, mock_project, mock_link_repo) + + assert '' in result + assert 'Home' in result + mock_link_repo.create.assert_called_once() + call_args = mock_link_repo.create.call_args + assert call_args[1]['link_type'] == 'homepage' + + def test_inject_homepage_link_not_found_in_content(self, mock_content, mock_project, mock_link_repo): + html = "This is about something totally different and unrelated content here.
" + article_url = "https://www.example.com/article.html" + + result = _inject_homepage_link(html, mock_content, article_url, mock_project, mock_link_repo) + + # Should still inject via fallback (using "Home" anchor text) + assert '' in result + assert 'Home' in result + + +class TestInjectTieredLinks: + """Tests for tiered link injection""" + + def test_tier1_money_site_link(self, mock_content, mock_project, mock_link_repo): + html = "Learn about shaft machining processes.
" + tiered_links = {"tier": 1, "money_site_url": "https://moneysite.com"} + job_config = None + + with patch('src.interlinking.content_injection.get_anchor_text_for_tier') as mock_get: + mock_get.return_value = ["shaft machining", "machining"] + result = _inject_tiered_links(html, mock_content, tiered_links, mock_project, job_config, mock_link_repo) + + assert '' in result + mock_link_repo.create.assert_called_once() + call_args = mock_link_repo.create.call_args + assert call_args[1]['link_type'] == 'tiered' + assert call_args[1]['to_url'] == 'https://moneysite.com' + + def test_tier2_lower_tier_links(self, mock_content, mock_project, mock_link_repo): + html = "This article discusses shaft machining and CNC processes and precision work.
" + mock_content.tier = "tier2" + tiered_links = { + "tier": 2, + "lower_tier": 1, + "lower_tier_urls": [ + "https://site1.com/article1.html", + "https://site2.com/article2.html" + ] + } + job_config = None + + with patch('src.interlinking.content_injection.get_anchor_text_for_tier') as mock_get: + mock_get.return_value = ["shaft machining", "CNC processes"] + result = _inject_tiered_links(html, mock_content, tiered_links, mock_project, job_config, mock_link_repo) + + # Should create links for both URLs + assert mock_link_repo.create.call_count == 2 + + def test_tier1_no_money_site(self, mock_content, mock_project, mock_link_repo): + html = "Content
" + tiered_links = {"tier": 1} + job_config = None + + result = _inject_tiered_links(html, mock_content, tiered_links, mock_project, job_config, mock_link_repo) + + # Should return original HTML with warning + assert result == html + mock_link_repo.create.assert_not_called() + + +class TestInjectInterlinks: + """Tests for main inject_interlinks function""" + + def test_empty_content_records(self, mock_project, mock_content_repo, mock_link_repo): + inject_interlinks([], [], {}, mock_project, None, mock_content_repo, mock_link_repo) + # Should not crash, just log warning + mock_content_repo.update.assert_not_called() + + def test_successful_injection(self, mock_content, mock_project, mock_content_repo, mock_link_repo): + article_urls = [ + {"content_id": 1, "title": "Article 1", "url": "https://example.com/article1.html"}, + {"content_id": 2, "title": "Article 2", "url": "https://example.com/article2.html"} + ] + tiered_links = {"tier": 1, "money_site_url": "https://moneysite.com"} + job_config = None + + with patch('src.interlinking.content_injection._inject_tiered_links') as mock_tiered, \ + patch('src.interlinking.content_injection._inject_homepage_link') as mock_home, \ + patch('src.interlinking.content_injection._inject_see_also_section') as mock_see_also: + + mock_tiered.return_value = "Updated content
" + mock_home.return_value = "Updated content
" + mock_see_also.return_value = "Updated content
" + + inject_interlinks( + [mock_content], + article_urls, + tiered_links, + mock_project, + job_config, + mock_content_repo, + mock_link_repo + ) + + mock_content_repo.update.assert_called_once() + + def test_missing_url_for_content(self, mock_content, mock_project, mock_content_repo, mock_link_repo): + article_urls = [ + {"content_id": 2, "title": "Article 2", "url": "https://example.com/article2.html"} + ] + tiered_links = {"tier": 1, "money_site_url": "https://moneysite.com"} + mock_content.id = 1 # ID not in article_urls + + inject_interlinks( + [mock_content], + article_urls, + tiered_links, + mock_project, + None, + mock_content_repo, + mock_link_repo + ) + + # Should skip this content + mock_content_repo.update.assert_not_called() +