Add Linker and Optimizer modules with API integration and frontend components
- Added Linker and Optimizer apps to `INSTALLED_APPS` in `settings.py`. - Configured API endpoints for Linker and Optimizer in `urls.py`. - Implemented `OptimizeContentFunction` for content optimization in the AI module. - Created prompts for content optimization and site structure generation. - Updated `OptimizerService` to utilize the new AI function for content optimization. - Developed frontend components including dashboards and content lists for Linker and Optimizer. - Integrated new routes and sidebar navigation for Linker and Optimizer in the frontend. - Enhanced content management with source and sync status filters in the Writer module. - Comprehensive test coverage added for new features and components.
This commit is contained in:
2
backend/igny8_core/business/linking/tests/__init__.py
Normal file
2
backend/igny8_core/business/linking/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Linking tests
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for CandidateEngine
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.candidate_engine import CandidateEngine
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class CandidateEngineTests(IntegrationTestBase):
|
||||
"""Tests for CandidateEngine"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = CandidateEngine()
|
||||
|
||||
# Create source content
|
||||
self.source_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Source Content",
|
||||
html_content="<p>Source content about test keyword.</p>",
|
||||
primary_keyword="test keyword",
|
||||
secondary_keywords=["keyword1", "keyword2"],
|
||||
categories=["category1"],
|
||||
tags=["tag1", "tag2"],
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create relevant content (same keyword)
|
||||
self.relevant_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Relevant Content",
|
||||
html_content="<p>Relevant content about test keyword.</p>",
|
||||
primary_keyword="test keyword",
|
||||
secondary_keywords=["keyword1"],
|
||||
categories=["category1"],
|
||||
tags=["tag1"],
|
||||
word_count=150,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create less relevant content (different keyword)
|
||||
self.less_relevant = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Less Relevant",
|
||||
html_content="<p>Different content.</p>",
|
||||
primary_keyword="different keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_find_candidates_returns_relevant_content(self):
|
||||
"""Test that find_candidates returns relevant content"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
# Should find relevant content
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertIn(self.relevant_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_scores_by_relevance(self):
|
||||
"""Test that candidates are scored by relevance"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
# Relevant content should have higher score
|
||||
relevant_candidate = next((c for c in candidates if c['content_id'] == self.relevant_content.id), None)
|
||||
self.assertIsNotNone(relevant_candidate)
|
||||
self.assertGreater(relevant_candidate['relevance_score'], 0)
|
||||
|
||||
def test_find_candidates_excludes_self(self):
|
||||
"""Test that source content is excluded from candidates"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertNotIn(self.source_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_respects_account_isolation(self):
|
||||
"""Test that candidates are only from same account"""
|
||||
# Create content from different account
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Account Content",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertNotIn(other_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_returns_empty_for_no_content(self):
|
||||
"""Test that empty list is returned when no content"""
|
||||
empty_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Empty",
|
||||
html_content="",
|
||||
word_count=0,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(empty_content, max_candidates=10)
|
||||
self.assertEqual(len(candidates), 0)
|
||||
|
||||
def test_find_candidates_respects_max_candidates(self):
|
||||
"""Test that max_candidates limit is respected"""
|
||||
# Create multiple relevant content items
|
||||
for i in range(15):
|
||||
Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title=f"Content {i}",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=5)
|
||||
self.assertLessEqual(len(candidates), 5)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Tests for InjectionEngine
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.injection_engine import InjectionEngine
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class InjectionEngineTests(IntegrationTestBase):
|
||||
"""Tests for InjectionEngine"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = InjectionEngine()
|
||||
|
||||
# Create content with HTML
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content with some keywords and text.</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_inject_links_adds_links_to_html(self):
|
||||
"""Test that links are injected into HTML content"""
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target Content',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'keywords'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Check that link was added
|
||||
self.assertIn('<a href="/content/1/" class="internal-link">keywords</a>', result['html_content'])
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
self.assertEqual(len(result['links']), 1)
|
||||
|
||||
def test_inject_links_respects_max_links(self):
|
||||
"""Test that max_links limit is respected"""
|
||||
candidates = [
|
||||
{'content_id': i, 'title': f'Content {i}', 'url': f'/content/{i}/',
|
||||
'relevance_score': 50, 'anchor_text': f'keyword{i}'}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
# Update HTML to include all anchor texts
|
||||
self.content.html_content = "<p>" + " ".join([f'keyword{i}' for i in range(10)]) + "</p>"
|
||||
self.content.save()
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=3)
|
||||
|
||||
self.assertLessEqual(result['links_added'], 3)
|
||||
self.assertLessEqual(len(result['links']), 3)
|
||||
|
||||
def test_inject_links_returns_unchanged_when_no_candidates(self):
|
||||
"""Test that content is unchanged when no candidates"""
|
||||
original_html = self.content.html_content
|
||||
|
||||
result = self.engine.inject_links(self.content, [], max_links=5)
|
||||
|
||||
self.assertEqual(result['html_content'], original_html)
|
||||
self.assertEqual(result['links_added'], 0)
|
||||
self.assertEqual(len(result['links']), 0)
|
||||
|
||||
def test_inject_links_returns_unchanged_when_no_html(self):
|
||||
"""Test that empty HTML returns unchanged"""
|
||||
self.content.html_content = ""
|
||||
self.content.save()
|
||||
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
self.assertEqual(result['html_content'], "")
|
||||
self.assertEqual(result['links_added'], 0)
|
||||
|
||||
def test_inject_links_case_insensitive_matching(self):
|
||||
"""Test that anchor text matching is case-insensitive"""
|
||||
self.content.html_content = "<p>This is TEST content.</p>"
|
||||
self.content.save()
|
||||
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Should find and replace despite case difference
|
||||
self.assertIn('internal-link', result['html_content'])
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
|
||||
def test_inject_links_prevents_duplicate_links(self):
|
||||
"""Test that same candidate is not linked twice"""
|
||||
candidates = [
|
||||
{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
},
|
||||
{
|
||||
'content_id': 1, # Same content_id
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 40,
|
||||
'anchor_text': 'test'
|
||||
}
|
||||
]
|
||||
|
||||
self.content.html_content = "<p>This is test content with test keywords.</p>"
|
||||
self.content.save()
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Should only add one link despite two candidates
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
self.assertEqual(result['html_content'].count('internal-link'), 1)
|
||||
|
||||
141
backend/igny8_core/business/linking/tests/test_linker_service.py
Normal file
141
backend/igny8_core/business/linking/tests/test_linker_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Tests for LinkerService
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class LinkerServiceTests(IntegrationTestBase):
|
||||
"""Tests for LinkerService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = LinkerService()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content with some keywords.</p>",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create another content for linking
|
||||
self.target_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Target Content",
|
||||
html_content="<p>Target content for linking.</p>",
|
||||
primary_keyword="test keyword",
|
||||
word_count=150,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CandidateEngine.find_candidates')
|
||||
@patch('igny8_core.business.linking.services.linker_service.InjectionEngine.inject_links')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.deduct_credits_for_operation')
|
||||
def test_process_single_content(self, mock_deduct, mock_inject, mock_find, mock_check):
|
||||
"""Test processing single content for linking"""
|
||||
# Setup mocks
|
||||
mock_check.return_value = True
|
||||
mock_find.return_value = [{
|
||||
'content_id': self.target_content.id,
|
||||
'title': 'Target Content',
|
||||
'url': '/content/2/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test keyword'
|
||||
}]
|
||||
mock_inject.return_value = {
|
||||
'html_content': '<p>This is test content with <a href="/content/2/">test keyword</a>.</p>',
|
||||
'links': [{
|
||||
'content_id': self.target_content.id,
|
||||
'anchor_text': 'test keyword',
|
||||
'url': '/content/2/'
|
||||
}],
|
||||
'links_added': 1
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = self.service.process(self.content.id)
|
||||
|
||||
# Assertions
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
self.assertEqual(result.linker_version, 1)
|
||||
self.assertEqual(len(result.internal_links), 1)
|
||||
mock_check.assert_called_once_with(self.account, 'linking')
|
||||
mock_deduct.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
def test_process_insufficient_credits(self, mock_check):
|
||||
"""Test that InsufficientCreditsError is raised when credits are insufficient"""
|
||||
mock_check.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
self.service.process(self.content.id)
|
||||
|
||||
def test_process_content_not_found(self):
|
||||
"""Test that ValueError is raised when content doesn't exist"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process(99999)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.LinkerService.process')
|
||||
def test_batch_process_multiple_content(self, mock_process):
|
||||
"""Test batch processing multiple content items"""
|
||||
# Create additional content
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
html_content="<p>Content 2</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Setup mock
|
||||
mock_process.side_effect = [self.content, content2]
|
||||
|
||||
# Execute
|
||||
results = self.service.batch_process([self.content.id, content2.id])
|
||||
|
||||
# Assertions
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(mock_process.call_count, 2)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.LinkerService.process')
|
||||
def test_batch_process_handles_partial_failure(self, mock_process):
|
||||
"""Test batch processing handles partial failures"""
|
||||
# Setup mock to fail on second item
|
||||
mock_process.side_effect = [self.content, Exception("Processing failed")]
|
||||
|
||||
# Execute
|
||||
results = self.service.batch_process([self.content.id, 99999])
|
||||
|
||||
# Assertions - should continue processing other items
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, self.content.id)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CandidateEngine.find_candidates')
|
||||
def test_process_no_candidates_found(self, mock_find, mock_check):
|
||||
"""Test processing when no candidates are found"""
|
||||
mock_check.return_value = True
|
||||
mock_find.return_value = []
|
||||
|
||||
# Execute
|
||||
result = self.service.process(self.content.id)
|
||||
|
||||
# Assertions - should return content unchanged
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
self.assertEqual(result.linker_version, 0) # Not incremented
|
||||
|
||||
Reference in New Issue
Block a user