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/billing/tests/__init__.py
Normal file
2
backend/igny8_core/business/billing/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Billing tests
|
||||
|
||||
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Tests for Phase 4 credit deduction
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class Phase4CreditTests(IntegrationTestBase):
|
||||
"""Tests for Phase 4 credit deduction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Set initial credits
|
||||
self.account.credits = 1000
|
||||
self.account.save()
|
||||
|
||||
def test_linking_deducts_correct_credits(self):
|
||||
"""Test that linking deducts correct credits"""
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
expected_cost = CREDIT_COSTS.get('linking', 0)
|
||||
|
||||
self.assertEqual(cost, expected_cost)
|
||||
self.assertEqual(cost, 8) # From constants
|
||||
|
||||
def test_optimization_deducts_correct_credits(self):
|
||||
"""Test that optimization deducts correct credits based on word count"""
|
||||
word_count = 500
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# Should be 1 credit per 200 words, so 500 words = 3 credits (max(1, 1 * 500/200) = 3)
|
||||
expected = max(1, int(CREDIT_COSTS.get('optimization', 1) * (word_count / 200)))
|
||||
self.assertEqual(cost, expected)
|
||||
|
||||
def test_optimization_credits_per_entry_point(self):
|
||||
"""Test that optimization credits are same regardless of entry point"""
|
||||
word_count = 400
|
||||
|
||||
# All entry points should use same credit calculation
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# 400 words = 2 credits (1 * 400/200)
|
||||
self.assertEqual(cost, 2)
|
||||
|
||||
@patch('igny8_core.business.billing.services.credit_service.CreditService.deduct_credits')
|
||||
def test_pipeline_deducts_credits_at_each_stage(self, mock_deduct):
|
||||
"""Test that pipeline deducts credits at each stage"""
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
word_count=400,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Mock the services
|
||||
with patch.object(LinkerService, 'process') as mock_link, \
|
||||
patch.object(OptimizerService, 'optimize_from_writer') as mock_optimize:
|
||||
|
||||
mock_link.return_value = content
|
||||
mock_optimize.return_value = content
|
||||
|
||||
service = ContentPipelineService()
|
||||
service.process_writer_content(content.id)
|
||||
|
||||
# Should deduct credits for both linking and optimization
|
||||
self.assertGreater(mock_deduct.call_count, 0)
|
||||
|
||||
def test_insufficient_credits_blocks_linking(self):
|
||||
"""Test that insufficient credits blocks linking"""
|
||||
self.account.credits = 5 # Less than linking cost (8)
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'linking')
|
||||
|
||||
def test_insufficient_credits_blocks_optimization(self):
|
||||
"""Test that insufficient credits blocks optimization"""
|
||||
self.account.credits = 1 # Less than optimization cost for 500 words
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'optimization', 500)
|
||||
|
||||
def test_credit_deduction_logged(self):
|
||||
"""Test that credit deduction is logged"""
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
|
||||
initial_credits = self.account.credits
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description="Test linking"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.credits, initial_credits - cost)
|
||||
|
||||
# Check that usage log was created
|
||||
log = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
operation_type='linking'
|
||||
).first()
|
||||
self.assertIsNotNone(log)
|
||||
|
||||
def test_batch_operations_deduct_multiple_credits(self):
|
||||
"""Test that batch operations deduct multiple credits"""
|
||||
initial_credits = self.account.credits
|
||||
linking_cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
# Deduct for 3 linking operations
|
||||
for i in range(3):
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description=f"Linking {i}"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
expected_credits = initial_credits - (linking_cost * 3)
|
||||
self.assertEqual(self.account.credits, expected_credits)
|
||||
|
||||
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Content tests
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for ContentPipelineService
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentPipelineServiceTests(IntegrationTestBase):
|
||||
"""Tests for ContentPipelineService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = ContentPipelineService()
|
||||
|
||||
# Create writer content
|
||||
self.writer_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Writer Content",
|
||||
html_content="<p>Writer content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Create synced content
|
||||
self.synced_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
html_content="<p>WordPress content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_full_pipeline(self, mock_optimize, mock_link):
|
||||
"""Test full pipeline for writer content (linking + optimization)"""
|
||||
mock_link.return_value = self.writer_content
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_optimization_only(self, mock_optimize):
|
||||
"""Test writer content with optimization only"""
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['optimization']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
def test_process_writer_content_linking_only(self, mock_link):
|
||||
"""Test writer content with linking only"""
|
||||
mock_link.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['linking']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_handles_linker_failure(self, mock_optimize, mock_link):
|
||||
"""Test that pipeline continues when linking fails"""
|
||||
mock_link.side_effect = Exception("Linking failed")
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
# Should not raise exception, should continue to optimization
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_wordpress_sync')
|
||||
def test_process_synced_content_wordpress(self, mock_optimize):
|
||||
"""Test synced content pipeline for WordPress"""
|
||||
mock_optimize.return_value = self.synced_content
|
||||
|
||||
result = self.service.process_synced_content(self.synced_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.synced_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_external_sync')
|
||||
def test_process_synced_content_shopify(self, mock_optimize):
|
||||
"""Test synced content pipeline for Shopify"""
|
||||
shopify_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
mock_optimize.return_value = shopify_content
|
||||
|
||||
result = self.service.process_synced_content(shopify_content.id)
|
||||
|
||||
self.assertEqual(result.id, shopify_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_manual')
|
||||
def test_process_synced_content_custom(self, mock_optimize):
|
||||
"""Test synced content pipeline for custom source"""
|
||||
custom_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Custom Content",
|
||||
word_count=100,
|
||||
source='custom'
|
||||
)
|
||||
mock_optimize.return_value = custom_content
|
||||
|
||||
result = self.service.process_synced_content(custom_content.id)
|
||||
|
||||
self.assertEqual(result.id, custom_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_writer_content(self, mock_process):
|
||||
"""Test batch processing writer content"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=100,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
mock_process.side_effect = [self.writer_content, content2]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
content2.id
|
||||
])
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(mock_process.call_count, 2)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_handles_partial_failure(self, mock_process):
|
||||
"""Test batch processing handles partial failures"""
|
||||
mock_process.side_effect = [self.writer_content, Exception("Failed")]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
99999
|
||||
])
|
||||
|
||||
# Should continue processing and return successful results
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, self.writer_content.id)
|
||||
|
||||
def test_process_writer_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_writer_content(99999)
|
||||
|
||||
def test_process_synced_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid synced content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_synced_content(99999)
|
||||
|
||||
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
|
||||
|
||||
@@ -176,8 +176,7 @@ class OptimizerService:
|
||||
|
||||
def _optimize_content(self, content: Content, scores_before: dict) -> Content:
|
||||
"""
|
||||
Internal method to optimize content.
|
||||
This is a placeholder - in production, this would call the AI function.
|
||||
Internal method to optimize content using AI function.
|
||||
|
||||
Args:
|
||||
content: Content to optimize
|
||||
@@ -186,14 +185,30 @@ class OptimizerService:
|
||||
Returns:
|
||||
Optimized Content instance
|
||||
"""
|
||||
# For now, return content as-is
|
||||
# In production, this would:
|
||||
# 1. Call OptimizeContentFunction AI function
|
||||
# 2. Get optimized HTML
|
||||
# 3. Update content
|
||||
from igny8_core.ai.engine import AIEngine
|
||||
from igny8_core.ai.registry import get_function_instance
|
||||
|
||||
# Prepare payload for AI function
|
||||
payload = {
|
||||
'ids': [content.id],
|
||||
}
|
||||
|
||||
# Get function from registry
|
||||
fn = get_function_instance('optimize_content')
|
||||
if not fn:
|
||||
raise ValueError("OptimizeContentFunction not found in registry")
|
||||
|
||||
# Execute AI function
|
||||
ai_engine = AIEngine(account=content.account)
|
||||
result = ai_engine.execute(fn, payload)
|
||||
|
||||
if not result.get('success'):
|
||||
raise ValueError(f"Optimization failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
# The AI function's save_output method already updates the content
|
||||
# We just need to refresh from database to get the updated content
|
||||
content.refresh_from_db()
|
||||
|
||||
# Placeholder: We'll implement AI function call later
|
||||
# For now, just return the content
|
||||
return content
|
||||
|
||||
def analyze_only(self, content_id: int) -> dict:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Optimization tests
|
||||
|
||||
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for ContentAnalyzer
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentAnalyzerTests(IntegrationTestBase):
|
||||
"""Tests for ContentAnalyzer"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.analyzer = ContentAnalyzer()
|
||||
|
||||
def test_analyze_returns_all_scores(self):
|
||||
"""Test that analyze returns all required scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertIn('seo_score', scores)
|
||||
self.assertIn('readability_score', scores)
|
||||
self.assertIn('engagement_score', scores)
|
||||
self.assertIn('overall_score', scores)
|
||||
self.assertIn('word_count', scores)
|
||||
self.assertIn('has_meta_title', scores)
|
||||
self.assertIn('has_meta_description', scores)
|
||||
self.assertIn('has_primary_keyword', scores)
|
||||
self.assertIn('internal_links_count', scores)
|
||||
|
||||
def test_analyze_returns_zero_scores_for_empty_content(self):
|
||||
"""Test that empty content returns zero scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Empty",
|
||||
html_content="",
|
||||
word_count=0,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertEqual(scores['seo_score'], 0)
|
||||
self.assertEqual(scores['readability_score'], 0)
|
||||
self.assertEqual(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['overall_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_meta_title(self):
|
||||
"""Test SEO score calculation with meta title"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
meta_title="Test Title" * 5, # 50 chars - optimal length
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_primary_keyword(self):
|
||||
"""Test SEO score calculation with primary keyword"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_readability_score(self):
|
||||
"""Test readability score calculation"""
|
||||
# Create content with good readability (short sentences, paragraphs)
|
||||
html = "<p>This is a sentence.</p><p>This is another sentence.</p><p>And one more.</p>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=20,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['readability_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_headings(self):
|
||||
"""Test engagement score calculation with headings"""
|
||||
html = "<h1>Main Heading</h1><h2>Subheading 1</h2><h2>Subheading 2</h2>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_internal_links(self):
|
||||
"""Test engagement score calculation with internal links"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
internal_links=[
|
||||
{'content_id': 1, 'anchor_text': 'link1'},
|
||||
{'content_id': 2, 'anchor_text': 'link2'},
|
||||
{'content_id': 3, 'anchor_text': 'link3'}
|
||||
],
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['internal_links_count'], 3)
|
||||
|
||||
def test_overall_score_is_weighted_average(self):
|
||||
"""Test that overall score is weighted average"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
# Overall should be weighted: SEO (40%) + Readability (30%) + Engagement (30%)
|
||||
expected = (
|
||||
scores['seo_score'] * 0.4 +
|
||||
scores['readability_score'] * 0.3 +
|
||||
scores['engagement_score'] * 0.3
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(scores['overall_score'], expected, places=1)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Tests for OptimizerService
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizerServiceTests(IntegrationTestBase):
|
||||
"""Tests for OptimizerService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = OptimizerService()
|
||||
|
||||
# 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.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_from_writer(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test optimize_from_writer entry point"""
|
||||
mock_check.return_value = True
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized Content",
|
||||
html_content="<p>Optimized content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize_from_writer(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_check.assert_called_once()
|
||||
mock_deduct.assert_called_once()
|
||||
|
||||
def test_optimize_from_writer_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(99999)
|
||||
|
||||
def test_optimize_from_writer_wrong_source(self):
|
||||
"""Test that ValueError is raised for wrong source"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(content.id)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
def test_optimize_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.optimize(self.content)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_creates_optimization_task(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test that optimization creates OptimizationTask"""
|
||||
mock_check.return_value = True
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized",
|
||||
html_content="<p>Optimized.</p>",
|
||||
word_count=500,
|
||||
status='draft'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize(self.content)
|
||||
|
||||
# Check that task was created
|
||||
task = OptimizationTask.objects.filter(content=self.content).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.status, 'completed')
|
||||
self.assertEqual(task.scores_before, scores)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
def test_analyze_only_returns_scores(self, mock_analyze, mock_check):
|
||||
"""Test analyze_only method returns scores without optimizing"""
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
result = self.service.analyze_only(self.content.id)
|
||||
|
||||
self.assertEqual(result, scores)
|
||||
mock_analyze.assert_called_once()
|
||||
|
||||
def test_optimize_from_wordpress_sync(self):
|
||||
"""Test optimize_from_wordpress_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_wordpress_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_from_external_sync(self):
|
||||
"""Test optimize_from_external_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_external_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_manual(self):
|
||||
"""Test optimize_manual entry point"""
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = self.content
|
||||
result = self.service.optimize_manual(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user