Backeup configs & cleanup of files and db
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
# Billing tests
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
"""
|
||||
Concurrency tests for payment approval
|
||||
Tests race conditions and concurrent approval attempts
|
||||
"""
|
||||
import pytest
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from decimal import Decimal
|
||||
from igny8_core.business.billing.models import (
|
||||
Invoice, Payment, Subscription, Plan, Account
|
||||
)
|
||||
from igny8_core.business.billing.views import approve_payment
|
||||
from unittest.mock import Mock
|
||||
import threading
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentApprovalConcurrencyTest(TransactionTestCase):
|
||||
"""Test concurrent payment approval scenarios"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create admin user
|
||||
self.admin = User.objects.create_user(
|
||||
email='admin@test.com',
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# Create account
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
owner=self.admin,
|
||||
credit_balance=0
|
||||
)
|
||||
|
||||
# Create plan
|
||||
self.plan = Plan.objects.create(
|
||||
name='Test Plan',
|
||||
slug='test-plan',
|
||||
price=Decimal('100.00'),
|
||||
currency='USD',
|
||||
billing_period='monthly',
|
||||
included_credits=1000
|
||||
)
|
||||
|
||||
# Create subscription
|
||||
self.subscription = Subscription.objects.create(
|
||||
account=self.account,
|
||||
plan=self.plan,
|
||||
status='pending_payment'
|
||||
)
|
||||
|
||||
# Create invoice
|
||||
self.invoice = Invoice.objects.create(
|
||||
account=self.account,
|
||||
invoice_number='INV-TEST-001',
|
||||
status='pending',
|
||||
subtotal=Decimal('100.00'),
|
||||
total_amount=Decimal('100.00'),
|
||||
currency='USD',
|
||||
invoice_type='subscription'
|
||||
)
|
||||
|
||||
# Create payment
|
||||
self.payment = Payment.objects.create(
|
||||
account=self.account,
|
||||
invoice=self.invoice,
|
||||
amount=Decimal('100.00'),
|
||||
currency='USD',
|
||||
payment_method='bank_transfer',
|
||||
status='pending_approval',
|
||||
manual_reference='TEST-REF-001'
|
||||
)
|
||||
|
||||
def test_concurrent_approval_attempts(self):
|
||||
"""
|
||||
Test that only one concurrent approval succeeds
|
||||
Multiple admins trying to approve same payment simultaneously
|
||||
"""
|
||||
num_threads = 5
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
results = []
|
||||
|
||||
def approve_payment_thread(payment_id, admin_user):
|
||||
"""Thread worker to approve payment"""
|
||||
try:
|
||||
# Simulate approval logic with transaction
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=payment_id)
|
||||
|
||||
# Check if already approved
|
||||
if payment.status == 'succeeded':
|
||||
return {'success': False, 'reason': 'already_approved'}
|
||||
|
||||
# Approve payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = admin_user
|
||||
payment.save()
|
||||
|
||||
# Update invoice
|
||||
invoice = payment.invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
return {'success': True}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# Create multiple threads attempting approval
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = []
|
||||
for i in range(num_threads):
|
||||
future = executor.submit(approve_payment_thread, self.payment.id, self.admin)
|
||||
futures.append(future)
|
||||
|
||||
# Collect results
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
if result.get('success'):
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
|
||||
# Verify only one approval succeeded
|
||||
self.assertEqual(success_count, 1, "Only one approval should succeed")
|
||||
self.assertEqual(failure_count, num_threads - 1, "Other attempts should fail")
|
||||
|
||||
# Verify final state
|
||||
payment = Payment.objects.get(id=self.payment.id)
|
||||
self.assertEqual(payment.status, 'succeeded')
|
||||
|
||||
invoice = Invoice.objects.get(id=self.invoice.id)
|
||||
self.assertEqual(invoice.status, 'paid')
|
||||
|
||||
def test_payment_and_invoice_consistency(self):
|
||||
"""
|
||||
Test that payment and invoice remain consistent under concurrent operations
|
||||
"""
|
||||
def read_payment_invoice(payment_id):
|
||||
"""Read payment and invoice status"""
|
||||
payment = Payment.objects.get(id=payment_id)
|
||||
invoice = Invoice.objects.get(id=payment.invoice_id)
|
||||
return {
|
||||
'payment_status': payment.status,
|
||||
'invoice_status': invoice.status,
|
||||
'consistent': (
|
||||
(payment.status == 'succeeded' and invoice.status == 'paid') or
|
||||
(payment.status == 'pending_approval' and invoice.status == 'pending')
|
||||
)
|
||||
}
|
||||
|
||||
# Approve payment in one thread
|
||||
def approve():
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
payment.status = 'succeeded'
|
||||
payment.save()
|
||||
|
||||
invoice = Invoice.objects.select_for_update().get(id=self.invoice.id)
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
# Read state in parallel threads
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
# Start approval
|
||||
approval_future = executor.submit(approve)
|
||||
|
||||
# Multiple concurrent reads
|
||||
read_futures = [
|
||||
executor.submit(read_payment_invoice, self.payment.id)
|
||||
for _ in range(20)
|
||||
]
|
||||
|
||||
# Wait for approval
|
||||
approval_future.result()
|
||||
|
||||
# Collect read results
|
||||
for future in as_completed(read_futures):
|
||||
results.append(future.result())
|
||||
|
||||
# All reads should show consistent state
|
||||
for result in results:
|
||||
self.assertTrue(
|
||||
result['consistent'],
|
||||
f"Inconsistent state: payment={result['payment_status']}, invoice={result['invoice_status']}"
|
||||
)
|
||||
|
||||
def test_double_approval_prevention(self):
|
||||
"""
|
||||
Test that payment cannot be approved twice
|
||||
"""
|
||||
# First approval
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = self.admin
|
||||
payment.save()
|
||||
|
||||
invoice = payment.invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.save()
|
||||
|
||||
# Attempt second approval
|
||||
result = None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(id=self.payment.id)
|
||||
|
||||
# Should detect already approved
|
||||
if payment.status == 'succeeded':
|
||||
result = 'already_approved'
|
||||
else:
|
||||
payment.status = 'succeeded'
|
||||
payment.save()
|
||||
result = 'approved'
|
||||
except Exception as e:
|
||||
result = f'error: {str(e)}'
|
||||
|
||||
self.assertEqual(result, 'already_approved', "Second approval should be prevented")
|
||||
|
||||
|
||||
class CreditTransactionConcurrencyTest(TransactionTestCase):
|
||||
"""Test concurrent credit additions/deductions"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
email='admin@test.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
owner=self.admin,
|
||||
credit_balance=1000
|
||||
)
|
||||
|
||||
def test_concurrent_credit_deductions(self):
|
||||
"""
|
||||
Test that concurrent credit deductions maintain correct balance
|
||||
"""
|
||||
initial_balance = self.account.credit_balance
|
||||
deduction_amount = 10
|
||||
num_operations = 20
|
||||
|
||||
def deduct_credits(account_id, amount):
|
||||
"""Deduct credits atomically"""
|
||||
from igny8_core.business.billing.models import CreditTransaction
|
||||
|
||||
with transaction.atomic():
|
||||
account = Account.objects.select_for_update().get(id=account_id)
|
||||
|
||||
# Check sufficient balance
|
||||
if account.credit_balance < amount:
|
||||
return {'success': False, 'reason': 'insufficient_credits'}
|
||||
|
||||
# Deduct credits
|
||||
account.credit_balance -= amount
|
||||
new_balance = account.credit_balance
|
||||
account.save()
|
||||
|
||||
# Record transaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='deduction',
|
||||
amount=-amount,
|
||||
balance_after=new_balance,
|
||||
description='Test deduction'
|
||||
)
|
||||
|
||||
return {'success': True, 'new_balance': new_balance}
|
||||
|
||||
# Concurrent deductions
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [
|
||||
executor.submit(deduct_credits, self.account.id, deduction_amount)
|
||||
for _ in range(num_operations)
|
||||
]
|
||||
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
# Verify all succeeded
|
||||
success_count = sum(1 for r in results if r.get('success'))
|
||||
self.assertEqual(success_count, num_operations, "All deductions should succeed")
|
||||
|
||||
# Verify final balance
|
||||
self.account.refresh_from_db()
|
||||
expected_balance = initial_balance - (deduction_amount * num_operations)
|
||||
self.assertEqual(
|
||||
self.account.credit_balance,
|
||||
expected_balance,
|
||||
f"Final balance should be {expected_balance}"
|
||||
)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Test payment method filtering by country
|
||||
"""
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from igny8_core.business.billing.models import PaymentMethodConfig
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentMethodFilteringTest(TestCase):
|
||||
"""Test payment method filtering by billing country"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create test payment method configs"""
|
||||
# Global methods (available everywhere)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='stripe',
|
||||
display_name='Credit/Debit Card',
|
||||
is_enabled=True,
|
||||
sort_order=1,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='paypal',
|
||||
display_name='PayPal',
|
||||
is_enabled=True,
|
||||
sort_order=2,
|
||||
)
|
||||
|
||||
# Country-specific methods
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='GB',
|
||||
payment_method='bank_transfer',
|
||||
display_name='Bank Transfer (UK)',
|
||||
is_enabled=True,
|
||||
sort_order=3,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='IN',
|
||||
payment_method='local_wallet',
|
||||
display_name='UPI/Wallets',
|
||||
is_enabled=True,
|
||||
sort_order=4,
|
||||
)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='PK',
|
||||
payment_method='bank_transfer',
|
||||
display_name='Bank Transfer (Pakistan)',
|
||||
is_enabled=True,
|
||||
sort_order=5,
|
||||
)
|
||||
|
||||
# Disabled method (should not appear)
|
||||
PaymentMethodConfig.objects.create(
|
||||
country_code='*',
|
||||
payment_method='manual',
|
||||
display_name='Manual',
|
||||
is_enabled=False,
|
||||
sort_order=99,
|
||||
)
|
||||
|
||||
self.client = Client()
|
||||
|
||||
def test_filter_payment_methods_by_us(self):
|
||||
"""Test filtering for US country - should get only global methods"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=US')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 2) # Only stripe and paypal
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
|
||||
def test_filter_payment_methods_by_gb(self):
|
||||
"""Test filtering for GB - should get global + GB-specific"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 3) # stripe, paypal, bank_transfer(GB)
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
self.assertIn('bank_transfer', methods)
|
||||
|
||||
def test_filter_payment_methods_by_in(self):
|
||||
"""Test filtering for IN - should get global + IN-specific"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=IN')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(len(data['results']), 3) # stripe, paypal, local_wallet(IN)
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
self.assertIn('local_wallet', methods)
|
||||
|
||||
def test_disabled_methods_not_returned(self):
|
||||
"""Test that disabled payment methods are not included"""
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=*')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertNotIn('manual', methods) # Disabled method should not appear
|
||||
|
||||
def test_sort_order_respected(self):
|
||||
\"\"\"Test that payment methods are returned in sort_order\"\"\"
|
||||
response = self.client.get('/api/v1/billing/admin/payment-methods/?country=GB')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
# Verify first method has lowest sort_order
|
||||
self.assertEqual(data['results'][0]['type'], 'stripe')
|
||||
self.assertEqual(data['results'][0]['sort_order'], 1)
|
||||
|
||||
def test_default_country_fallback(self):
|
||||
"""Test that missing country parameter defaults to global (*)\"\"\"\n response = self.client.get('/api/v1/billing/admin/payment-methods/')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertTrue(data['success'])
|
||||
# Should get at least global methods
|
||||
methods = [m['type'] for m in data['results']]
|
||||
self.assertIn('stripe', methods)
|
||||
self.assertIn('paypal', methods)
|
||||
@@ -1,192 +0,0 @@
|
||||
"""
|
||||
Integration tests for payment workflow
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
|
||||
from igny8_core.auth.models import Account, Plan, Subscription
|
||||
from igny8_core.business.billing.models import Invoice, Payment
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentWorkflowIntegrationTest(TestCase):
|
||||
"""Test complete payment workflow including invoice.subscription FK"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create test data"""
|
||||
# Create plan
|
||||
self.plan = Plan.objects.create(
|
||||
name='Test Plan',
|
||||
slug='test-plan',
|
||||
price=Decimal('29.00'),
|
||||
included_credits=1000,
|
||||
max_sites=5,
|
||||
)
|
||||
|
||||
# Create account
|
||||
self.account = Account.objects.create(
|
||||
name='Test Account',
|
||||
slug='test-account',
|
||||
status='pending_payment',
|
||||
billing_country='US',
|
||||
billing_email='test@example.com',
|
||||
)
|
||||
|
||||
# Create user
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='testuser@example.com',
|
||||
password='testpass123',
|
||||
account=self.account,
|
||||
)
|
||||
|
||||
# Create subscription
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
self.subscription = Subscription.objects.create(
|
||||
account=self.account,
|
||||
plan=self.plan,
|
||||
status='pending_payment',
|
||||
current_period_start=billing_period_start,
|
||||
current_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
def test_invoice_subscription_fk_relationship(self):
|
||||
"""Test that invoice.subscription FK works correctly"""
|
||||
# Create invoice via service
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Verify FK relationship
|
||||
self.assertIsNotNone(invoice.subscription)
|
||||
self.assertEqual(invoice.subscription.id, self.subscription.id)
|
||||
self.assertEqual(invoice.subscription.plan.id, self.plan.id)
|
||||
|
||||
# Verify can access subscription from invoice
|
||||
self.assertEqual(invoice.subscription.account, self.account)
|
||||
self.assertEqual(invoice.subscription.plan.name, 'Test Plan')
|
||||
|
||||
def test_payment_approval_with_subscription(self):
|
||||
"""Test payment approval workflow uses invoice.subscription"""
|
||||
# Create invoice
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Create payment
|
||||
payment = Payment.objects.create(
|
||||
account=self.account,
|
||||
invoice=invoice,
|
||||
amount=invoice.total,
|
||||
currency='USD',
|
||||
status='pending_approval',
|
||||
payment_method='bank_transfer',
|
||||
manual_reference='TEST-REF-001',
|
||||
)
|
||||
|
||||
# Verify payment links to invoice which links to subscription
|
||||
self.assertIsNotNone(payment.invoice)
|
||||
self.assertIsNotNone(payment.invoice.subscription)
|
||||
self.assertEqual(payment.invoice.subscription.id, self.subscription.id)
|
||||
|
||||
# Simulate approval workflow
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = self.user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.save()
|
||||
|
||||
# Update related records
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
subscription = invoice.subscription
|
||||
subscription.status = 'active'
|
||||
subscription.save()
|
||||
|
||||
# Verify workflow completed successfully
|
||||
self.assertEqual(payment.status, 'succeeded')
|
||||
self.assertEqual(invoice.status, 'paid')
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.plan.included_credits, 1000)
|
||||
|
||||
def test_subscription_dates_not_null_for_paid_plans(self):
|
||||
"""Test that subscription dates are set for paid plans"""
|
||||
self.assertIsNotNone(self.subscription.current_period_start)
|
||||
self.assertIsNotNone(self.subscription.current_period_end)
|
||||
|
||||
# Verify dates are in future
|
||||
self.assertGreater(self.subscription.current_period_end, self.subscription.current_period_start)
|
||||
|
||||
def test_invoice_currency_based_on_country(self):
|
||||
"""Test that invoice currency is set based on billing country"""
|
||||
# Test US -> USD
|
||||
self.account.billing_country = 'US'
|
||||
self.account.save()
|
||||
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice_us = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_us.currency, 'USD')
|
||||
|
||||
# Test GB -> GBP
|
||||
self.account.billing_country = 'GB'
|
||||
self.account.save()
|
||||
|
||||
invoice_gb = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_gb.currency, 'GBP')
|
||||
|
||||
# Test IN -> INR
|
||||
self.account.billing_country = 'IN'
|
||||
self.account.save()
|
||||
|
||||
invoice_in = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
self.assertEqual(invoice_in.currency, 'INR')
|
||||
|
||||
def test_invoice_due_date_grace_period(self):
|
||||
"""Test that invoice due date uses grace period instead of billing_period_end"""
|
||||
billing_period_start = timezone.now()
|
||||
billing_period_end = billing_period_start + timedelta(days=30)
|
||||
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=self.subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
|
||||
# Verify due date is invoice_date + 7 days (grace period)
|
||||
expected_due_date = invoice.invoice_date + timedelta(days=7)
|
||||
self.assertEqual(invoice.due_date, expected_due_date)
|
||||
|
||||
# Verify it's NOT billing_period_end
|
||||
self.assertNotEqual(invoice.due_date, billing_period_end.date())
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Content tests
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Tests for Universal Content Types (Phase 8)
|
||||
Tests for product, service, and taxonomy content generation
|
||||
"""
|
||||
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_generation_service import ContentGenerationService
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class UniversalContentTypesTests(IntegrationTestBase):
|
||||
"""Tests for Phase 8: Universal Content Types"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Add credits to account for testing
|
||||
self.account.credits = 10000
|
||||
self.account.save()
|
||||
self.service = ContentGenerationService()
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_product_content_generates_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Product content generates correctly
|
||||
Task 17: Verify product generation creates content with correct entity_type and structure
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-123'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
product_data = {
|
||||
'name': 'Test Product',
|
||||
'description': 'A test product description',
|
||||
'features': ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
'target_audience': 'Small businesses',
|
||||
'primary_keyword': 'test product',
|
||||
'word_count': 1500
|
||||
}
|
||||
|
||||
# Generate product content
|
||||
result = self.service.generate_product_content(
|
||||
product_data=product_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Product content generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_product_content')
|
||||
self.assertEqual(call_args[1]['payload']['product_name'], 'Test Product')
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_service_pages_work_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Service pages work correctly
|
||||
Task 18: Verify service page generation creates content with correct entity_type
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-456'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
service_data = {
|
||||
'name': 'Test Service',
|
||||
'description': 'A test service description',
|
||||
'benefits': ['Benefit 1', 'Benefit 2', 'Benefit 3'],
|
||||
'target_audience': 'Enterprise clients',
|
||||
'primary_keyword': 'test service',
|
||||
'word_count': 1800
|
||||
}
|
||||
|
||||
# Generate service page
|
||||
result = self.service.generate_service_page(
|
||||
service_data=service_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Service page generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_service_page')
|
||||
self.assertEqual(call_args[1]['payload']['service_name'], 'Test Service')
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_taxonomy_pages_work_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Taxonomy pages work correctly
|
||||
Task 19: Verify taxonomy generation creates content with correct entity_type
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-789'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
taxonomy_data = {
|
||||
'name': 'Test Taxonomy',
|
||||
'description': 'A test taxonomy description',
|
||||
'items': ['Category 1', 'Category 2', 'Category 3'],
|
||||
'primary_keyword': 'test taxonomy',
|
||||
'word_count': 1200
|
||||
}
|
||||
|
||||
# Generate taxonomy
|
||||
result = self.service.generate_taxonomy(
|
||||
taxonomy_data=taxonomy_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Taxonomy generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_taxonomy')
|
||||
self.assertEqual(call_args[1]['payload']['taxonomy_name'], 'Test Taxonomy')
|
||||
|
||||
def test_product_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Product content generates correctly
|
||||
Task 17: Verify product content has correct entity_type, json_blocks, and structure_data
|
||||
"""
|
||||
# Create product content manually to test structure
|
||||
product_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Product',
|
||||
html_content='<p>Product content</p>',
|
||||
entity_type='product',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'product_overview',
|
||||
'heading': 'Product Overview',
|
||||
'content': 'Product description'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'heading': 'Key Features',
|
||||
'items': ['Feature 1', 'Feature 2']
|
||||
},
|
||||
{
|
||||
'type': 'specifications',
|
||||
'heading': 'Specifications',
|
||||
'data': {'Spec 1': 'Value 1'}
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'product_type': 'software',
|
||||
'price_range': '$99-$199',
|
||||
'target_market': 'SMB'
|
||||
},
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(product_content.entity_type, 'product')
|
||||
self.assertIsNotNone(product_content.json_blocks)
|
||||
self.assertEqual(len(product_content.json_blocks), 3)
|
||||
self.assertEqual(product_content.json_blocks[0]['type'], 'product_overview')
|
||||
self.assertIsNotNone(product_content.structure_data)
|
||||
self.assertEqual(product_content.structure_data['product_type'], 'software')
|
||||
|
||||
def test_service_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Service pages work correctly
|
||||
Task 18: Verify service content has correct entity_type and json_blocks
|
||||
"""
|
||||
# Create service content manually to test structure
|
||||
service_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Service',
|
||||
html_content='<p>Service content</p>',
|
||||
entity_type='service',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'service_overview',
|
||||
'heading': 'Service Overview',
|
||||
'content': 'Service description'
|
||||
},
|
||||
{
|
||||
'type': 'benefits',
|
||||
'heading': 'Benefits',
|
||||
'items': ['Benefit 1', 'Benefit 2']
|
||||
},
|
||||
{
|
||||
'type': 'process',
|
||||
'heading': 'Our Process',
|
||||
'steps': ['Step 1', 'Step 2']
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'service_type': 'consulting',
|
||||
'duration': '3-6 months',
|
||||
'target_market': 'Enterprise'
|
||||
},
|
||||
word_count=1800,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(service_content.entity_type, 'service')
|
||||
self.assertIsNotNone(service_content.json_blocks)
|
||||
self.assertEqual(len(service_content.json_blocks), 3)
|
||||
self.assertEqual(service_content.json_blocks[0]['type'], 'service_overview')
|
||||
self.assertIsNotNone(service_content.structure_data)
|
||||
self.assertEqual(service_content.structure_data['service_type'], 'consulting')
|
||||
|
||||
def test_taxonomy_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Taxonomy pages work correctly
|
||||
Task 19: Verify taxonomy content has correct entity_type and json_blocks
|
||||
"""
|
||||
# Create taxonomy content manually to test structure
|
||||
taxonomy_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Taxonomy',
|
||||
html_content='<p>Taxonomy content</p>',
|
||||
entity_type='taxonomy',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'taxonomy_overview',
|
||||
'heading': 'Taxonomy Overview',
|
||||
'content': 'Taxonomy description'
|
||||
},
|
||||
{
|
||||
'type': 'categories',
|
||||
'heading': 'Categories',
|
||||
'items': [
|
||||
{
|
||||
'name': 'Category 1',
|
||||
'description': 'Category description',
|
||||
'subcategories': ['Subcat 1', 'Subcat 2']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'tags',
|
||||
'heading': 'Tags',
|
||||
'items': ['Tag 1', 'Tag 2', 'Tag 3']
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'taxonomy_type': 'product_categories',
|
||||
'item_count': 10,
|
||||
'hierarchy_levels': 3
|
||||
},
|
||||
word_count=1200,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(taxonomy_content.entity_type, 'taxonomy')
|
||||
self.assertIsNotNone(taxonomy_content.json_blocks)
|
||||
self.assertEqual(len(taxonomy_content.json_blocks), 3)
|
||||
self.assertEqual(taxonomy_content.json_blocks[0]['type'], 'taxonomy_overview')
|
||||
self.assertIsNotNone(taxonomy_content.structure_data)
|
||||
self.assertEqual(taxonomy_content.structure_data['taxonomy_type'], 'product_categories')
|
||||
@@ -374,100 +374,10 @@ class ContentSyncService:
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
|
||||
|
||||
# Get or create site blueprint for this site
|
||||
blueprint = SiteBlueprint.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site
|
||||
).first()
|
||||
|
||||
if not blueprint:
|
||||
logger.warning(f"No blueprint found for site {integration.site.id}, skipping taxonomy sync")
|
||||
return {'success': True, 'synced_count': 0}
|
||||
|
||||
taxonomy_service = TaxonomyService()
|
||||
synced_count = 0
|
||||
|
||||
# Sync WordPress categories
|
||||
categories = client.get_categories(per_page=100)
|
||||
category_records = [
|
||||
{
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'description': cat.get('description', ''),
|
||||
'taxonomy_type': 'blog_category',
|
||||
'external_reference': str(cat['id']),
|
||||
'metadata': {'parent': cat.get('parent', 0)}
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
if category_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
category_records,
|
||||
default_type='blog_category'
|
||||
)
|
||||
synced_count += len(category_records)
|
||||
|
||||
# Sync WordPress tags
|
||||
tags = client.get_tags(per_page=100)
|
||||
tag_records = [
|
||||
{
|
||||
'name': tag['name'],
|
||||
'slug': tag['slug'],
|
||||
'description': tag.get('description', ''),
|
||||
'taxonomy_type': 'blog_tag',
|
||||
'external_reference': str(tag['id'])
|
||||
}
|
||||
for tag in tags
|
||||
]
|
||||
if tag_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
tag_records,
|
||||
default_type='blog_tag'
|
||||
)
|
||||
synced_count += len(tag_records)
|
||||
|
||||
# Sync WooCommerce product categories if available (401 is expected if WooCommerce not installed or credentials missing)
|
||||
try:
|
||||
product_categories = client.get_product_categories(per_page=100)
|
||||
product_category_records = [
|
||||
{
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'description': cat.get('description', ''),
|
||||
'taxonomy_type': 'product_category',
|
||||
'external_reference': f"wc_cat_{cat['id']}",
|
||||
'metadata': {'parent': cat.get('parent', 0)}
|
||||
}
|
||||
for cat in product_categories
|
||||
]
|
||||
if product_category_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
product_category_records,
|
||||
default_type='product_category'
|
||||
)
|
||||
synced_count += len(product_category_records)
|
||||
except Exception as e:
|
||||
# Silently skip WooCommerce if not available (401 means no consumer key/secret configured or plugin not installed)
|
||||
logger.debug(f"WooCommerce product categories not available: {e}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing taxonomies from WordPress: {e}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
# REMOVED: Legacy SiteBlueprint taxonomy sync removed.
|
||||
# Taxonomy management now uses ContentTaxonomy model.
|
||||
logger.info(f"Skipping legacy taxonomy sync for site {integration.site.id}")
|
||||
return {'success': True, 'synced_count': 0}
|
||||
|
||||
def _sync_taxonomies_to_wordpress(
|
||||
self,
|
||||
|
||||
@@ -308,11 +308,14 @@ class SyncHealthService:
|
||||
"""
|
||||
Detect mismatches between IGNY8 and WordPress.
|
||||
|
||||
DEPRECATED: Legacy SiteBlueprint taxonomy mismatch detection removed.
|
||||
Taxonomy management now uses ContentTaxonomy model.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
dict: Mismatch details
|
||||
dict: Mismatch details (empty for now)
|
||||
"""
|
||||
mismatches = {
|
||||
'taxonomies': {
|
||||
@@ -330,116 +333,8 @@ class SyncHealthService:
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
from igny8_core.utils.wordpress import WordPressClient
|
||||
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
site_url=integration.config_json.get('site_url', ''),
|
||||
username=credentials.get('username'),
|
||||
app_password=credentials.get('app_password')
|
||||
)
|
||||
|
||||
# Get site blueprint
|
||||
blueprint = SiteBlueprint.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site
|
||||
).first()
|
||||
|
||||
if not blueprint:
|
||||
return mismatches
|
||||
|
||||
# Check taxonomy mismatches
|
||||
# Get IGNY8 taxonomies
|
||||
igny8_taxonomies = SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint
|
||||
)
|
||||
|
||||
# Get WordPress categories
|
||||
wp_categories = client.get_categories(per_page=100)
|
||||
wp_category_ids = {str(cat['id']): cat for cat in wp_categories}
|
||||
|
||||
# Get WordPress tags
|
||||
wp_tags = client.get_tags(per_page=100)
|
||||
wp_tag_ids = {str(tag['id']): tag for tag in wp_tags}
|
||||
|
||||
for taxonomy in igny8_taxonomies:
|
||||
if taxonomy.external_reference:
|
||||
# Check if still exists in WordPress
|
||||
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
|
||||
if taxonomy.external_reference not in wp_category_ids:
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type,
|
||||
'external_reference': taxonomy.external_reference
|
||||
})
|
||||
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
|
||||
if taxonomy.external_reference not in wp_tag_ids:
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type,
|
||||
'external_reference': taxonomy.external_reference
|
||||
})
|
||||
else:
|
||||
# Taxonomy exists in IGNY8 but not synced to WordPress
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type
|
||||
})
|
||||
|
||||
# Check for WordPress taxonomies not in IGNY8
|
||||
for cat in wp_categories:
|
||||
if not SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint,
|
||||
external_reference=str(cat['id'])
|
||||
).exists():
|
||||
mismatches['taxonomies']['missing_in_igny8'].append({
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'type': 'blog_category',
|
||||
'external_reference': str(cat['id'])
|
||||
})
|
||||
|
||||
for tag in wp_tags:
|
||||
if not SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint,
|
||||
external_reference=str(tag['id'])
|
||||
).exists():
|
||||
mismatches['taxonomies']['missing_in_igny8'].append({
|
||||
'name': tag['name'],
|
||||
'slug': tag['slug'],
|
||||
'type': 'blog_tag',
|
||||
'external_reference': str(tag['id'])
|
||||
})
|
||||
|
||||
# Check content mismatches (basic check)
|
||||
igny8_content = Content.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
source='igny8',
|
||||
status='publish'
|
||||
)
|
||||
|
||||
for content in igny8_content[:50]: # Limit check
|
||||
if content.metadata and content.metadata.get('wordpress_id'):
|
||||
# Content should exist in WordPress (would need to check)
|
||||
# For now, just note if metadata exists
|
||||
pass
|
||||
else:
|
||||
# Content not synced to WordPress
|
||||
mismatches['posts']['missing_in_wordpress'].append({
|
||||
'id': content.id,
|
||||
'title': content.title,
|
||||
'type': content.content_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error detecting WordPress mismatches: {e}")
|
||||
# Legacy taxonomy detection removed - would need to be reimplemented with ContentTaxonomy
|
||||
logger.info(f"Mismatch detection for integration {integration.id} - legacy code removed")
|
||||
|
||||
return mismatches
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Integration Tests
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""
|
||||
Tests for ContentSyncService
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
|
||||
class ContentSyncServiceTestCase(TestCase):
|
||||
"""Test cases for ContentSyncService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
self.integration = SiteIntegration.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
platform='wordpress',
|
||||
platform_type='cms',
|
||||
sync_enabled=True
|
||||
)
|
||||
self.service = ContentSyncService()
|
||||
|
||||
def test_sync_content_from_wordpress_creates_content(self):
|
||||
"""Test: WordPress sync works (when plugin connected)"""
|
||||
mock_posts = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'Test Post',
|
||||
'content': '<p>Test content</p>',
|
||||
'status': 'publish',
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(self.service, '_fetch_wordpress_posts') as mock_fetch:
|
||||
mock_fetch.return_value = mock_posts
|
||||
|
||||
result = self.service.sync_from_wordpress(self.integration)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('synced_count'), 1)
|
||||
|
||||
# Verify content was created
|
||||
content = Content.objects.filter(site=self.site).first()
|
||||
self.assertIsNotNone(content)
|
||||
self.assertEqual(content.title, 'Test Post')
|
||||
self.assertEqual(content.source, 'wordpress')
|
||||
|
||||
def test_sync_content_from_shopify_creates_content(self):
|
||||
"""Test: Content sync works"""
|
||||
mock_products = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'Test Product',
|
||||
'body_html': '<p>Product description</p>',
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(self.service, '_fetch_shopify_products') as mock_fetch:
|
||||
mock_fetch.return_value = mock_products
|
||||
|
||||
result = self.service.sync_from_shopify(self.integration)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('synced_count'), 1)
|
||||
|
||||
def test_sync_handles_duplicate_content(self):
|
||||
"""Test: Content sync works"""
|
||||
# Create existing content
|
||||
Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Post",
|
||||
html_content="<p>Existing</p>",
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
mock_posts = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'Test Post',
|
||||
'content': '<p>Updated content</p>',
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(self.service, '_fetch_wordpress_posts') as mock_fetch:
|
||||
mock_fetch.return_value = mock_posts
|
||||
|
||||
result = self.service.sync_from_wordpress(self.integration)
|
||||
|
||||
# Should update existing, not create duplicate
|
||||
content_count = Content.objects.filter(
|
||||
site=self.site,
|
||||
title='Test Post'
|
||||
).count()
|
||||
self.assertEqual(content_count, 1)
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Tests for IntegrationService
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.business.integration.services.integration_service import IntegrationService
|
||||
|
||||
|
||||
class IntegrationServiceTestCase(TestCase):
|
||||
"""Test cases for IntegrationService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
self.service = IntegrationService()
|
||||
|
||||
def test_create_integration_stores_config(self):
|
||||
"""Test: Site integrations work correctly"""
|
||||
integration = self.service.create_integration(
|
||||
site=self.site,
|
||||
platform='wordpress',
|
||||
config={'url': 'https://example.com'},
|
||||
credentials={'api_key': 'test-key'},
|
||||
platform_type='cms'
|
||||
)
|
||||
|
||||
self.assertIsNotNone(integration)
|
||||
self.assertEqual(integration.platform, 'wordpress')
|
||||
self.assertEqual(integration.platform_type, 'cms')
|
||||
self.assertEqual(integration.config_json.get('url'), 'https://example.com')
|
||||
self.assertTrue(integration.is_active)
|
||||
|
||||
def test_get_integrations_for_site_returns_all(self):
|
||||
"""Test: Site integrations work correctly"""
|
||||
self.service.create_integration(
|
||||
site=self.site,
|
||||
platform='wordpress',
|
||||
config={},
|
||||
credentials={}
|
||||
)
|
||||
self.service.create_integration(
|
||||
site=self.site,
|
||||
platform='shopify',
|
||||
config={},
|
||||
credentials={}
|
||||
)
|
||||
|
||||
integrations = self.service.get_integrations_for_site(self.site)
|
||||
|
||||
self.assertEqual(integrations.count(), 2)
|
||||
platforms = [i.platform for i in integrations]
|
||||
self.assertIn('wordpress', platforms)
|
||||
self.assertIn('shopify', platforms)
|
||||
|
||||
def test_test_connection_validates_credentials(self):
|
||||
"""Test: Site integrations work correctly"""
|
||||
# Test with unsupported platform to verify NotImplementedError is raised
|
||||
integration = self.service.create_integration(
|
||||
site=self.site,
|
||||
platform='unsupported_platform',
|
||||
config={'url': 'https://example.com'},
|
||||
credentials={'api_key': 'test-key'}
|
||||
)
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
# Connection testing should raise NotImplementedError for unsupported platforms
|
||||
self.service.test_connection(integration)
|
||||
|
||||
def test_update_integration_updates_fields(self):
|
||||
"""Test: Site integrations work correctly"""
|
||||
integration = self.service.create_integration(
|
||||
site=self.site,
|
||||
platform='wordpress',
|
||||
config={'url': 'https://old.com'},
|
||||
credentials={}
|
||||
)
|
||||
|
||||
updated = self.service.update_integration(
|
||||
integration,
|
||||
config={'url': 'https://new.com'},
|
||||
is_active=False
|
||||
)
|
||||
|
||||
self.assertEqual(updated.config_json.get('url'), 'https://new.com')
|
||||
self.assertFalse(updated.is_active)
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
Tests for SyncService
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.business.integration.services.sync_service import SyncService
|
||||
|
||||
|
||||
class SyncServiceTestCase(TestCase):
|
||||
"""Test cases for SyncService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
self.integration = SiteIntegration.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
platform='wordpress',
|
||||
platform_type='cms',
|
||||
sync_enabled=True,
|
||||
sync_status='pending'
|
||||
)
|
||||
self.service = SyncService()
|
||||
|
||||
def test_sync_updates_status(self):
|
||||
"""Test: Two-way sync functions properly"""
|
||||
with patch.object(self.service, '_sync_to_external') as mock_sync_to, \
|
||||
patch.object(self.service, '_sync_from_external') as mock_sync_from:
|
||||
mock_sync_to.return_value = {'success': True, 'synced': 5}
|
||||
mock_sync_from.return_value = {'success': True, 'synced': 3}
|
||||
|
||||
result = self.service.sync(self.integration, direction='both')
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.integration.refresh_from_db()
|
||||
self.assertEqual(self.integration.sync_status, 'success')
|
||||
self.assertIsNotNone(self.integration.last_sync_at)
|
||||
|
||||
def test_sync_to_external_only(self):
|
||||
"""Test: Two-way sync functions properly"""
|
||||
with patch.object(self.service, '_sync_to_external') as mock_sync_to:
|
||||
mock_sync_to.return_value = {'success': True, 'synced': 5}
|
||||
|
||||
result = self.service.sync(self.integration, direction='to_external')
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
mock_sync_to.assert_called_once()
|
||||
|
||||
def test_sync_from_external_only(self):
|
||||
"""Test: WordPress sync works (when plugin connected)"""
|
||||
with patch.object(self.service, '_sync_from_external') as mock_sync_from:
|
||||
mock_sync_from.return_value = {'success': True, 'synced': 3}
|
||||
|
||||
result = self.service.sync(self.integration, direction='from_external')
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
mock_sync_from.assert_called_once()
|
||||
|
||||
def test_sync_handles_errors(self):
|
||||
"""Test: Two-way sync functions properly"""
|
||||
with patch.object(self.service, '_sync_to_external') as mock_sync_to:
|
||||
mock_sync_to.side_effect = Exception("Sync failed")
|
||||
|
||||
result = self.service.sync(self.integration, direction='to_external')
|
||||
|
||||
self.assertFalse(result.get('success'))
|
||||
self.integration.refresh_from_db()
|
||||
self.assertEqual(self.integration.sync_status, 'failed')
|
||||
self.assertIsNotNone(self.integration.sync_error)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Linking tests
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Tests for Universal Content Types Linking (Phase 8)
|
||||
Tests for product and taxonomy linking
|
||||
"""
|
||||
from unittest.mock import 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.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class UniversalContentLinkingTests(IntegrationTestBase):
|
||||
"""Tests for Phase 8: Universal Content Types Linking"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Add credits to account for testing
|
||||
self.account.credits = 10000
|
||||
self.account.save()
|
||||
self.linker_service = LinkerService()
|
||||
|
||||
# Create product content
|
||||
self.product_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Product',
|
||||
html_content='<p>Product content with features and specifications.</p>',
|
||||
entity_type='product',
|
||||
json_blocks=[
|
||||
{'type': 'features', 'heading': 'Features', 'items': ['Feature 1', 'Feature 2']}
|
||||
],
|
||||
structure_data={'product_type': 'software'},
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create related product
|
||||
self.related_product = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Related Product',
|
||||
html_content='<p>Related product content.</p>',
|
||||
entity_type='product',
|
||||
structure_data={'product_type': 'software'},
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create service content
|
||||
self.service_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Related Service',
|
||||
html_content='<p>Service content.</p>',
|
||||
entity_type='service',
|
||||
word_count=1800,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create taxonomy content
|
||||
self.taxonomy_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Taxonomy',
|
||||
html_content='<p>Taxonomy content with categories.</p>',
|
||||
entity_type='taxonomy',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'categories',
|
||||
'heading': 'Categories',
|
||||
'items': [
|
||||
{'name': 'Category 1', 'description': 'Desc 1', 'subcategories': []}
|
||||
]
|
||||
}
|
||||
],
|
||||
word_count=1200,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create related taxonomy
|
||||
self.related_taxonomy = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Related Taxonomy',
|
||||
html_content='<p>Related taxonomy content.</p>',
|
||||
entity_type='taxonomy',
|
||||
word_count=1200,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.InjectionEngine.inject_links')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.deduct_credits_for_operation')
|
||||
def test_linking_works_for_products(self, mock_deduct, mock_check_credits, mock_inject_links):
|
||||
"""
|
||||
Test: Linking works for all content types (products, taxonomies)
|
||||
Task 20: Verify product linking finds related products and services
|
||||
"""
|
||||
# Mock injection engine
|
||||
mock_inject_links.return_value = {
|
||||
'html_content': '<p>Product content with links.</p>',
|
||||
'links': [
|
||||
{'content_id': self.related_product.id, 'anchor_text': 'Related Product'},
|
||||
{'content_id': self.service_content.id, 'anchor_text': 'Related Service'}
|
||||
],
|
||||
'links_added': 2
|
||||
}
|
||||
|
||||
# Process product linking
|
||||
result = self.linker_service.process_product(self.product_content.id)
|
||||
|
||||
# Verify result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.entity_type, 'product')
|
||||
self.assertIsNotNone(result.internal_links)
|
||||
self.assertEqual(len(result.internal_links), 2)
|
||||
self.assertEqual(result.linker_version, 1)
|
||||
|
||||
# Verify injection was called
|
||||
mock_inject_links.assert_called_once()
|
||||
candidates = mock_inject_links.call_args[0][1]
|
||||
self.assertGreater(len(candidates), 0)
|
||||
|
||||
# Verify product candidates were found
|
||||
product_candidates = [c for c in candidates if c.get('content_id') == self.related_product.id]
|
||||
self.assertGreater(len(product_candidates), 0)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.InjectionEngine.inject_links')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.deduct_credits_for_operation')
|
||||
def test_linking_works_for_taxonomies(self, mock_deduct, mock_check_credits, mock_inject_links):
|
||||
"""
|
||||
Test: Linking works for all content types (products, taxonomies)
|
||||
Task 20: Verify taxonomy linking finds related taxonomies and content
|
||||
"""
|
||||
# Mock injection engine
|
||||
mock_inject_links.return_value = {
|
||||
'html_content': '<p>Taxonomy content with links.</p>',
|
||||
'links': [
|
||||
{'content_id': self.related_taxonomy.id, 'anchor_text': 'Related Taxonomy'}
|
||||
],
|
||||
'links_added': 1
|
||||
}
|
||||
|
||||
# Process taxonomy linking
|
||||
result = self.linker_service.process_taxonomy(self.taxonomy_content.id)
|
||||
|
||||
# Verify result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.entity_type, 'taxonomy')
|
||||
self.assertIsNotNone(result.internal_links)
|
||||
self.assertEqual(len(result.internal_links), 1)
|
||||
self.assertEqual(result.linker_version, 1)
|
||||
|
||||
# Verify injection was called
|
||||
mock_inject_links.assert_called_once()
|
||||
candidates = mock_inject_links.call_args[0][1]
|
||||
self.assertGreater(len(candidates), 0)
|
||||
|
||||
# Verify taxonomy candidates were found
|
||||
taxonomy_candidates = [c for c in candidates if c.get('content_id') == self.related_taxonomy.id]
|
||||
self.assertGreater(len(taxonomy_candidates), 0)
|
||||
|
||||
def test_product_linking_finds_related_products(self):
|
||||
"""
|
||||
Test: Linking works for all content types (products, taxonomies)
|
||||
Task 20: Verify _find_product_candidates finds related products
|
||||
"""
|
||||
candidates = self.linker_service._find_product_candidates(self.product_content)
|
||||
|
||||
# Should find related product
|
||||
product_ids = [c['content_id'] for c in candidates]
|
||||
self.assertIn(self.related_product.id, product_ids)
|
||||
|
||||
# Should find related service
|
||||
self.assertIn(self.service_content.id, product_ids)
|
||||
|
||||
def test_taxonomy_linking_finds_related_taxonomies(self):
|
||||
"""
|
||||
Test: Linking works for all content types (products, taxonomies)
|
||||
Task 20: Verify _find_taxonomy_candidates finds related taxonomies
|
||||
"""
|
||||
candidates = self.linker_service._find_taxonomy_candidates(self.taxonomy_content)
|
||||
|
||||
# Should find related taxonomy
|
||||
taxonomy_ids = [c['content_id'] for c in candidates]
|
||||
self.assertIn(self.related_taxonomy.id, taxonomy_ids)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Optimization tests
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""
|
||||
Tests for Universal Content Types Optimization (Phase 8)
|
||||
Tests for product and taxonomy optimization
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class UniversalContentOptimizationTests(IntegrationTestBase):
|
||||
"""Tests for Phase 8: Universal Content Types Optimization"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Add credits to account for testing
|
||||
self.account.credits = 10000
|
||||
self.account.save()
|
||||
self.optimizer_service = OptimizerService()
|
||||
|
||||
# Create product content
|
||||
self.product_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Product',
|
||||
html_content='<p>Product content that needs optimization.</p>',
|
||||
entity_type='product',
|
||||
json_blocks=[
|
||||
{'type': 'features', 'heading': 'Features', 'items': ['Feature 1']},
|
||||
{'type': 'specifications', 'heading': 'Specs', 'data': {'Spec': 'Value'}}
|
||||
],
|
||||
structure_data={'product_type': 'software', 'price_range': '$99-$199'},
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create taxonomy content
|
||||
self.taxonomy_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Taxonomy',
|
||||
html_content='<p>Taxonomy content that needs optimization.</p>',
|
||||
entity_type='taxonomy',
|
||||
json_blocks=[
|
||||
{'type': 'categories', 'heading': 'Categories', 'items': [{'name': 'Cat 1'}]},
|
||||
{'type': 'tags', 'heading': 'Tags', 'items': ['Tag 1']},
|
||||
{'type': 'hierarchy', 'heading': 'Hierarchy', 'structure': {}}
|
||||
],
|
||||
word_count=1200,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.get_credit_cost')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimization_works_for_products(self, mock_deduct, mock_get_cost, mock_check_credits, mock_analyze, mock_optimize):
|
||||
"""
|
||||
Test: Optimization works for all content types (products, taxonomies)
|
||||
Task 21: Verify product optimization includes product-specific metrics
|
||||
"""
|
||||
# Mock analyzer
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 75,
|
||||
'readability_score': 80,
|
||||
'engagement_score': 70,
|
||||
'overall_score': 75
|
||||
}
|
||||
|
||||
# Mock credit cost
|
||||
mock_get_cost.return_value = 10
|
||||
|
||||
# Mock optimization
|
||||
optimized_content = Content.objects.get(id=self.product_content.id)
|
||||
optimized_content.html_content = '<p>Optimized product content.</p>'
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
# Optimize product
|
||||
result = self.optimizer_service.optimize_product(self.product_content.id)
|
||||
|
||||
# Verify result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.entity_type, 'product')
|
||||
self.assertEqual(result.optimizer_version, 1)
|
||||
self.assertIsNotNone(result.optimization_scores)
|
||||
|
||||
# Verify product-specific scores were enhanced
|
||||
scores = result.optimization_scores
|
||||
self.assertIn('product_completeness', scores)
|
||||
self.assertGreaterEqual(scores['product_completeness'], 0)
|
||||
self.assertLessEqual(scores['product_completeness'], 1)
|
||||
|
||||
# Verify optimization task was created
|
||||
task = OptimizationTask.objects.filter(content=result).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.status, 'completed')
|
||||
self.assertIn('product_completeness', task.scores_after)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.get_credit_cost')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimization_works_for_taxonomies(self, mock_deduct, mock_get_cost, mock_check_credits, mock_analyze, mock_optimize):
|
||||
"""
|
||||
Test: Optimization works for all content types (products, taxonomies)
|
||||
Task 21: Verify taxonomy optimization includes taxonomy-specific metrics
|
||||
"""
|
||||
# Mock analyzer
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 70,
|
||||
'readability_score': 75,
|
||||
'engagement_score': 65,
|
||||
'overall_score': 70
|
||||
}
|
||||
|
||||
# Mock credit cost
|
||||
mock_get_cost.return_value = 8
|
||||
|
||||
# Mock optimization
|
||||
optimized_content = Content.objects.get(id=self.taxonomy_content.id)
|
||||
optimized_content.html_content = '<p>Optimized taxonomy content.</p>'
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
# Optimize taxonomy
|
||||
result = self.optimizer_service.optimize_taxonomy(self.taxonomy_content.id)
|
||||
|
||||
# Verify result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.entity_type, 'taxonomy')
|
||||
self.assertEqual(result.optimizer_version, 1)
|
||||
self.assertIsNotNone(result.optimization_scores)
|
||||
|
||||
# Verify taxonomy-specific scores were enhanced
|
||||
scores = result.optimization_scores
|
||||
self.assertIn('taxonomy_organization', scores)
|
||||
self.assertGreaterEqual(scores['taxonomy_organization'], 0)
|
||||
self.assertLessEqual(scores['taxonomy_organization'], 1)
|
||||
|
||||
# Verify optimization task was created
|
||||
task = OptimizationTask.objects.filter(content=result).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.status, 'completed')
|
||||
self.assertIn('taxonomy_organization', task.scores_after)
|
||||
|
||||
def test_enhance_product_scores_includes_completeness(self):
|
||||
"""
|
||||
Test: Optimization works for all content types (products, taxonomies)
|
||||
Task 21: Verify _enhance_product_scores adds product_completeness
|
||||
"""
|
||||
base_scores = {
|
||||
'seo_score': 75,
|
||||
'readability_score': 80,
|
||||
'overall_score': 75
|
||||
}
|
||||
|
||||
enhanced = self.optimizer_service._enhance_product_scores(base_scores, self.product_content)
|
||||
|
||||
self.assertIn('product_completeness', enhanced)
|
||||
self.assertGreaterEqual(enhanced['product_completeness'], 0)
|
||||
self.assertLessEqual(enhanced['product_completeness'], 1)
|
||||
|
||||
def test_enhance_taxonomy_scores_includes_organization(self):
|
||||
"""
|
||||
Test: Optimization works for all content types (products, taxonomies)
|
||||
Task 21: Verify _enhance_taxonomy_scores adds taxonomy_organization
|
||||
"""
|
||||
base_scores = {
|
||||
'seo_score': 70,
|
||||
'readability_score': 75,
|
||||
'overall_score': 70
|
||||
}
|
||||
|
||||
enhanced = self.optimizer_service._enhance_taxonomy_scores(base_scores, self.taxonomy_content)
|
||||
|
||||
self.assertIn('taxonomy_organization', enhanced)
|
||||
self.assertGreaterEqual(enhanced['taxonomy_organization'], 0)
|
||||
self.assertLessEqual(enhanced['taxonomy_organization'], 1)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Publishing Tests
|
||||
Phase 5: Sites Renderer & Publishing
|
||||
"""
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
Tests for Publishing Adapters
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
|
||||
from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter
|
||||
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
|
||||
|
||||
class AdapterPatternTestCase(TestCase):
|
||||
"""Test cases for adapter pattern"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
self.blueprint = SiteBlueprint.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
name="Test Blueprint",
|
||||
status='ready'
|
||||
)
|
||||
|
||||
def test_sites_renderer_adapter_implements_base_interface(self):
|
||||
"""Test: Adapter pattern works correctly"""
|
||||
adapter = SitesRendererAdapter()
|
||||
|
||||
self.assertIsInstance(adapter, BaseAdapter)
|
||||
self.assertTrue(hasattr(adapter, 'publish'))
|
||||
self.assertTrue(hasattr(adapter, 'test_connection'))
|
||||
self.assertTrue(hasattr(adapter, 'get_status'))
|
||||
|
||||
def test_wordpress_adapter_implements_base_interface(self):
|
||||
"""Test: Adapter pattern works correctly"""
|
||||
adapter = WordPressAdapter()
|
||||
|
||||
self.assertIsInstance(adapter, BaseAdapter)
|
||||
self.assertTrue(hasattr(adapter, 'publish'))
|
||||
self.assertTrue(hasattr(adapter, 'test_connection'))
|
||||
self.assertTrue(hasattr(adapter, 'get_status'))
|
||||
|
||||
def test_sites_renderer_adapter_deploys_site(self):
|
||||
"""Test: Multi-destination publishing works"""
|
||||
adapter = SitesRendererAdapter()
|
||||
|
||||
result = adapter.deploy(self.blueprint)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('deployment_url'))
|
||||
self.assertIsNotNone(result.get('version'))
|
||||
|
||||
def test_wordpress_adapter_publishes_content(self):
|
||||
"""Test: Multi-destination publishing works"""
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test</p>"
|
||||
)
|
||||
|
||||
adapter = WordPressAdapter()
|
||||
config = {
|
||||
'site_url': 'https://example.com',
|
||||
'username': 'test',
|
||||
'app_password': 'test'
|
||||
}
|
||||
|
||||
# Patch WordPressClient at the point where it's used in the adapter
|
||||
with patch('igny8_core.business.publishing.services.adapters.wordpress_adapter.WordPressClient') as mock_client_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.create_post.return_value = {'id': 123, 'link': 'https://example.com/post/123'}
|
||||
mock_client_class.return_value = mock_instance
|
||||
|
||||
result = adapter.publish(content, config)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('external_id'))
|
||||
self.assertIsNotNone(result.get('url'))
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
DEPRECATED: Tests for DeploymentService - SiteBlueprint models removed
|
||||
Phase 5: Sites Renderer & Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.publishing.models import DeploymentRecord
|
||||
from igny8_core.business.publishing.services.deployment_service import DeploymentService
|
||||
|
||||
|
||||
class DeploymentServiceTestCase(TestCase):
|
||||
"""DEPRECATED: Test cases for DeploymentService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
# DEPRECATED: SiteBlueprint model removed
|
||||
self.blueprint = None
|
||||
self.service = DeploymentService()
|
||||
|
||||
def test_get_status_returns_deployed_record(self):
|
||||
"""Test: Sites are accessible publicly"""
|
||||
DeploymentRecord.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
site_blueprint=self.blueprint,
|
||||
version=1,
|
||||
status='deployed',
|
||||
deployment_url='https://test-site.igny8.com',
|
||||
deployed_at=timezone.now()
|
||||
)
|
||||
|
||||
status = self.service.get_status(self.blueprint)
|
||||
|
||||
self.assertIsNotNone(status)
|
||||
self.assertEqual(status.status, 'deployed')
|
||||
self.assertEqual(status.deployment_url, 'https://test-site.igny8.com')
|
||||
|
||||
def test_get_latest_deployment_returns_most_recent(self):
|
||||
"""Test: Deployment works end-to-end"""
|
||||
DeploymentRecord.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
site_blueprint=self.blueprint,
|
||||
version=1,
|
||||
status='failed',
|
||||
created_at=timezone.now()
|
||||
)
|
||||
|
||||
latest = DeploymentRecord.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
site_blueprint=self.blueprint,
|
||||
version=2,
|
||||
status='deployed',
|
||||
deployment_url='https://test-site.igny8.com',
|
||||
deployed_at=timezone.now()
|
||||
)
|
||||
|
||||
result = self.service.get_latest_deployment(self.blueprint)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.version, 2)
|
||||
self.assertEqual(result.status, 'deployed')
|
||||
|
||||
def test_rollback_reverts_to_previous_version(self):
|
||||
"""Test: Deployment works end-to-end"""
|
||||
DeploymentRecord.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
site_blueprint=self.blueprint,
|
||||
version=1,
|
||||
status='deployed',
|
||||
deployment_url='https://test-site.igny8.com',
|
||||
deployed_at=timezone.now()
|
||||
)
|
||||
|
||||
result = self.service.rollback(self.blueprint, target_version=1)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.blueprint.refresh_from_db()
|
||||
self.assertEqual(self.blueprint.deployed_version, 1)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""
|
||||
Tests for PublisherService
|
||||
Phase 5: Sites Renderer & Publishing
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord
|
||||
from igny8_core.business.publishing.services.publisher_service import PublisherService
|
||||
|
||||
|
||||
class PublisherServiceTestCase(TestCase):
|
||||
"""Test cases for PublisherService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
# Create plan first
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
# Create industry and sector
|
||||
self.industry = Industry.objects.create(
|
||||
name="Test Industry",
|
||||
slug="test-industry"
|
||||
)
|
||||
|
||||
self.industry_sector = IndustrySector.objects.create(
|
||||
industry=self.industry,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
|
||||
self.site = Site.objects.create(
|
||||
account=self.account,
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
industry=self.industry
|
||||
)
|
||||
self.sector = Sector.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
industry_sector=self.industry_sector,
|
||||
name="Test Sector",
|
||||
slug="test-sector"
|
||||
)
|
||||
self.blueprint = SiteBlueprint.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
name="Test Blueprint",
|
||||
status='ready'
|
||||
)
|
||||
self.service = PublisherService()
|
||||
|
||||
def test_publish_to_sites_creates_deployment_record(self):
|
||||
"""Test: Deployment works end-to-end"""
|
||||
# Don't mock deploy - let it run to create the deployment record
|
||||
# But mock the filesystem operations to avoid actual file writes
|
||||
with patch('igny8_core.business.publishing.services.adapters.sites_renderer_adapter.Path.mkdir'), \
|
||||
patch('igny8_core.business.publishing.services.adapters.sites_renderer_adapter.open', create=True) as mock_open:
|
||||
mock_file = mock_open.return_value.__enter__.return_value
|
||||
|
||||
result = self.service.publish_to_sites(self.blueprint)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('deployment_url'))
|
||||
|
||||
# Verify deployment record was created
|
||||
deployment = DeploymentRecord.objects.filter(site_blueprint=self.blueprint).first()
|
||||
self.assertIsNotNone(deployment)
|
||||
|
||||
def test_get_deployment_status_returns_latest(self):
|
||||
"""Test: Sites are accessible publicly"""
|
||||
DeploymentRecord.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
site_blueprint=self.blueprint,
|
||||
version=1,
|
||||
status='deployed',
|
||||
deployment_url='https://test-site.igny8.com',
|
||||
deployed_at=timezone.now()
|
||||
)
|
||||
|
||||
status = self.service.get_deployment_status(self.blueprint)
|
||||
|
||||
self.assertIsNotNone(status)
|
||||
self.assertEqual(status.status, 'deployed')
|
||||
self.assertIsNotNone(status.deployment_url)
|
||||
|
||||
def test_publish_content_to_multiple_destinations(self):
|
||||
"""Test: Multi-destination publishing works"""
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test</p>"
|
||||
)
|
||||
|
||||
with patch.object(self.service, '_get_adapter') as mock_get_adapter:
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.publish.return_value = {
|
||||
'success': True,
|
||||
'external_id': '123',
|
||||
'url': 'https://example.com/post/123'
|
||||
}
|
||||
mock_get_adapter.return_value = mock_adapter
|
||||
|
||||
result = self.service.publish_content(
|
||||
content_id=content.id,
|
||||
destinations=['wordpress', 'sites'],
|
||||
account=self.account
|
||||
)
|
||||
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(len(result.get('results', [])), 2)
|
||||
|
||||
# Verify publishing records were created
|
||||
records = PublishingRecord.objects.filter(content=content)
|
||||
self.assertEqual(records.count(), 2)
|
||||
|
||||
Reference in New Issue
Block a user