Backeup configs & cleanup of files and db
This commit is contained in:
115
CLEANUP_SUMMARY_20260113.md
Normal file
115
CLEANUP_SUMMARY_20260113.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Cleanup Summary - January 13, 2026
|
||||
|
||||
## ✅ Test Files Removed
|
||||
|
||||
### Test Directories Deleted:
|
||||
- `igny8_core/modules/optimizer/tests/` - All optimizer test files
|
||||
- `igny8_core/modules/linker/tests/` - All linker test files
|
||||
- `igny8_core/ai/functions/tests/` - AI function tests
|
||||
- `igny8_core/ai/tests/` - AI module tests
|
||||
- `igny8_core/api/tests/` - API integration tests
|
||||
- `igny8_core/business/publishing/tests/` - Publishing tests (including deprecated SiteBlueprint tests)
|
||||
- `igny8_core/business/billing/tests/` - Billing tests
|
||||
- `igny8_core/business/integration/tests/` - Integration tests
|
||||
- `igny8_core/business/content/tests/` - Content pipeline tests
|
||||
- `igny8_core/business/optimization/tests/` - Optimization tests
|
||||
- `igny8_core/business/linking/tests/` - Linking tests
|
||||
|
||||
### Test Config Files Removed:
|
||||
- `igny8_core/test_settings.py` - Test-specific Django settings
|
||||
|
||||
**Total:** ~38 test files removed across 11 directories
|
||||
|
||||
---
|
||||
|
||||
## ✅ SiteBlueprint/SiteBuilder Cleanup Status
|
||||
|
||||
### Database:
|
||||
- ✅ Table `igny8_site_blueprints` - **DROPPED** (migration 0030)
|
||||
- ✅ All indexes removed
|
||||
- ✅ Sequence dropped
|
||||
- ✅ No foreign key constraints remain in active models
|
||||
|
||||
### Code References:
|
||||
- ✅ No active model definitions (SiteBlueprint, PageBlueprint, SiteBlueprintTaxonomy)
|
||||
- ✅ No directory `/business/site_building/` exists
|
||||
- ✅ No admin registrations
|
||||
- ✅ No URL endpoints
|
||||
- ✅ No API serializers with active references
|
||||
- ✅ No celery tasks referencing blueprints
|
||||
|
||||
### Remaining References (Comments Only):
|
||||
- Historical migration files (0001_initial.py, etc.) - **LEFT INTACT** (should never be modified)
|
||||
- Code comments marking removal - **LEFT INTACT** (documentation of changes)
|
||||
- Docstrings explaining legacy functionality - **LEFT INTACT** (helpful context)
|
||||
|
||||
### Services Updated:
|
||||
- `content_sync_service.py` - SiteBlueprint taxonomy sync replaced with stub
|
||||
- `sync_health_service.py` - Taxonomy mismatch detection replaced with stub
|
||||
- `publisher_service.py` - Already had SiteBlueprint publishing removed
|
||||
- `deployment_service.py` - Already marked as deprecated
|
||||
|
||||
---
|
||||
|
||||
## ✅ One-Time Fix Files Analysis
|
||||
|
||||
### Migration Fix Files Found (NOT REMOVED - Required for Database History):
|
||||
- `0002_fix_cluster_unique_constraint.py` - Cluster model constraint fix
|
||||
- `0012_fix_subscription_constraints.py` - Subscription model constraints
|
||||
- `0020_fix_historical_account.py` - Historical tracking fix
|
||||
- `0003_phase1b_fix_taxonomy_relation.py` - Taxonomy relation fix
|
||||
- `0007_fix_cluster_unique_constraint.py` - Another cluster fix
|
||||
- `0022_fix_historical_calculation_mode_null.py` - Billing calculation fix
|
||||
- `0009_fix_variables_optional.py` - System variables fix
|
||||
- `0006_fix_image_settings.py` - Image settings fix
|
||||
- `0004_fix_global_settings_remove_override.py` - Global settings fix
|
||||
|
||||
**Note:** Migration files are never deleted - they form the database schema history.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Verification Results
|
||||
|
||||
### Django System Check:
|
||||
```
|
||||
System check identified no issues (0 silenced).
|
||||
```
|
||||
|
||||
### Database Check:
|
||||
```
|
||||
Did not find any relation named "igny8_site_blueprints"
|
||||
```
|
||||
|
||||
### Backend Status:
|
||||
- ✅ Backend container restarted successfully
|
||||
- ✅ No import errors
|
||||
- ✅ No model reference errors
|
||||
- ✅ All apps load correctly
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Statistics
|
||||
|
||||
- **Test files removed:** ~38 files
|
||||
- **Test directories removed:** 11 directories
|
||||
- **Database tables dropped:** 1 table (igny8_site_blueprints)
|
||||
- **Active code references removed:** ~120 lines
|
||||
- **Services cleaned:** 3 services
|
||||
- **Django check result:** ✅ 0 issues
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What Remains (Intentionally)
|
||||
|
||||
1. **Historical Migrations:** All migration files preserved (required for database integrity)
|
||||
2. **Code Comments:** Documentation of removed features (helpful for understanding)
|
||||
3. **Migration Fix Files:** Database schema fixes (part of migration history)
|
||||
4. **Docstrings:** Legacy documentation in deprecated functions
|
||||
|
||||
---
|
||||
|
||||
## ✅ Cleanup Complete
|
||||
|
||||
All test files removed, SiteBlueprint/SiteBuilder functionality fully cleaned from active codebase.
|
||||
Database table dropped successfully. System verified and running without issues.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# AI functions tests
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
Tests for OptimizeContentFunction
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizeContentFunctionTests(IntegrationTestBase):
|
||||
"""Tests for OptimizeContentFunction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.function = OptimizeContentFunction()
|
||||
|
||||
# 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'
|
||||
)
|
||||
|
||||
def test_function_validation_phase(self):
|
||||
"""Test validation phase"""
|
||||
# Valid payload
|
||||
result = self.function.validate({'ids': [self.content.id]}, self.account)
|
||||
self.assertTrue(result['valid'])
|
||||
|
||||
# Invalid payload - missing ids
|
||||
result = self.function.validate({}, self.account)
|
||||
self.assertFalse(result['valid'])
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_function_prep_phase(self):
|
||||
"""Test prep phase"""
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
data = self.function.prepare(payload, self.account)
|
||||
|
||||
self.assertIn('content', data)
|
||||
self.assertIn('scores_before', data)
|
||||
self.assertIn('html_content', data)
|
||||
self.assertEqual(data['content'].id, self.content.id)
|
||||
|
||||
def test_function_prep_phase_content_not_found(self):
|
||||
"""Test prep phase with non-existent content"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
@patch('igny8_core.ai.functions.optimize_content.PromptRegistry.get_prompt')
|
||||
def test_function_build_prompt(self, mock_get_prompt):
|
||||
"""Test prompt building"""
|
||||
mock_get_prompt.return_value = "Test prompt"
|
||||
|
||||
data = {
|
||||
'content': self.content,
|
||||
'html_content': '<p>Test</p>',
|
||||
'meta_title': 'Title',
|
||||
'meta_description': 'Description',
|
||||
'primary_keyword': 'keyword',
|
||||
'scores_before': {'overall_score': 50.0}
|
||||
}
|
||||
|
||||
prompt = self.function.build_prompt(data, self.account)
|
||||
|
||||
self.assertEqual(prompt, "Test prompt")
|
||||
mock_get_prompt.assert_called_once()
|
||||
# Check that context was passed
|
||||
call_args = mock_get_prompt.call_args
|
||||
self.assertIn('context', call_args.kwargs)
|
||||
|
||||
def test_function_parse_response_valid_json(self):
|
||||
"""Test parsing valid JSON response"""
|
||||
response = '{"html_content": "<p>Optimized</p>", "meta_title": "New Title"}'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
self.assertEqual(parsed['meta_title'], "New Title")
|
||||
|
||||
def test_function_parse_response_invalid_json(self):
|
||||
"""Test parsing invalid JSON response"""
|
||||
response = "This is not JSON"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.parse_response(response)
|
||||
|
||||
def test_function_parse_response_extracts_json_object(self):
|
||||
"""Test that JSON object is extracted from text"""
|
||||
response = 'Some text {"html_content": "<p>Optimized</p>"} more text'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
|
||||
@patch('igny8_core.business.optimization.services.analyzer.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.content.services.content_generation_service.ContentGenerationService._count_words')
|
||||
def test_function_save_phase(self, mock_count_words, mock_analyze):
|
||||
"""Test save phase updates content"""
|
||||
mock_count_words.return_value = 600
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 75.0,
|
||||
'readability_score': 80.0,
|
||||
'engagement_score': 70.0,
|
||||
'overall_score': 75.0
|
||||
}
|
||||
|
||||
parsed = {
|
||||
'html_content': '<p>Optimized content.</p>',
|
||||
'meta_title': 'Optimized Title',
|
||||
'meta_description': 'Optimized Description'
|
||||
}
|
||||
|
||||
original_data = {
|
||||
'content': self.content,
|
||||
'scores_before': {'overall_score': 50.0},
|
||||
'word_count': 500
|
||||
}
|
||||
|
||||
result = self.function.save_output(parsed, original_data, self.account)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['content_id'], self.content.id)
|
||||
|
||||
# Refresh content from DB
|
||||
self.content.refresh_from_db()
|
||||
self.assertEqual(self.content.html_content, '<p>Optimized content.</p>')
|
||||
self.assertEqual(self.content.optimizer_version, 1)
|
||||
self.assertIsNotNone(self.content.optimization_scores)
|
||||
|
||||
def test_function_handles_invalid_content_id(self):
|
||||
"""Test that function handles invalid content ID"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
def test_function_respects_account_isolation(self):
|
||||
"""Test that function respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
# Should not find content from different account
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, other_account)
|
||||
|
||||
def test_get_name(self):
|
||||
"""Test get_name method"""
|
||||
self.assertEqual(self.function.get_name(), 'optimize_content')
|
||||
|
||||
def test_get_metadata(self):
|
||||
"""Test get_metadata method"""
|
||||
metadata = self.function.get_metadata()
|
||||
|
||||
self.assertIn('display_name', metadata)
|
||||
self.assertIn('description', metadata)
|
||||
self.assertIn('phases', metadata)
|
||||
self.assertEqual(metadata['display_name'], 'Optimize Content')
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
|
||||
from igny8_core.business.site_building.models import PageBlueprint
|
||||
from igny8_core.business.site_building.tests.base import SiteBuilderTestBase
|
||||
|
||||
|
||||
class GenerateSiteStructureFunctionTests(SiteBuilderTestBase):
|
||||
"""Covers parsing + persistence logic for the Site Builder AI function."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.function = GenerateSiteStructureFunction()
|
||||
|
||||
def test_parse_response_extracts_json_object(self):
|
||||
noisy_response = """
|
||||
Thoughts about the request…
|
||||
{
|
||||
"site": {"name": "Acme Robotics"},
|
||||
"pages": [{"slug": "home", "title": "Home"}]
|
||||
}
|
||||
Extra commentary that should be ignored.
|
||||
"""
|
||||
parsed = self.function.parse_response(noisy_response)
|
||||
self.assertEqual(parsed['site']['name'], 'Acme Robotics')
|
||||
self.assertEqual(parsed['pages'][0]['slug'], 'home')
|
||||
|
||||
def test_save_output_updates_structure_and_syncs_pages(self):
|
||||
# Existing page to prove update/delete flows.
|
||||
legacy_page = PageBlueprint.objects.create(
|
||||
site_blueprint=self.blueprint,
|
||||
slug='legacy',
|
||||
title='Legacy Page',
|
||||
type='custom',
|
||||
blocks_json=[],
|
||||
order=5,
|
||||
)
|
||||
|
||||
parsed = {
|
||||
'site': {'name': 'Future Robotics'},
|
||||
'pages': [
|
||||
{
|
||||
'slug': 'home',
|
||||
'title': 'Homepage',
|
||||
'type': 'home',
|
||||
'status': 'ready',
|
||||
'blocks': [{'type': 'hero', 'heading': 'Build faster'}],
|
||||
},
|
||||
{
|
||||
'slug': 'about',
|
||||
'title': 'About Us',
|
||||
'type': 'about',
|
||||
'blocks': [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = self.function.save_output(parsed, {'blueprint': self.blueprint})
|
||||
|
||||
self.blueprint.refresh_from_db()
|
||||
self.assertEqual(self.blueprint.status, 'ready')
|
||||
self.assertEqual(self.blueprint.structure_json['site']['name'], 'Future Robotics')
|
||||
self.assertEqual(result['pages_created'], 1)
|
||||
self.assertEqual(result['pages_updated'], 1)
|
||||
self.assertEqual(result['pages_deleted'], 1)
|
||||
|
||||
slugs = set(self.blueprint.pages.values_list('slug', flat=True))
|
||||
self.assertIn('home', slugs)
|
||||
self.assertIn('about', slugs)
|
||||
self.assertNotIn(legacy_page.slug, slugs)
|
||||
|
||||
def test_build_prompt_includes_existing_pages(self):
|
||||
# Convert structure to JSON to ensure template rendering stays stable.
|
||||
data = self.function.prepare(
|
||||
payload={'ids': [self.blueprint.id]},
|
||||
account=self.account,
|
||||
)
|
||||
prompt = self.function.build_prompt(data, account=self.account)
|
||||
self.assertIn(self.blueprint.name, prompt)
|
||||
self.assertIn('Home', prompt)
|
||||
# The prompt should mention hosting type and objectives in JSON context.
|
||||
self.assertIn(self.blueprint.hosting_type, prompt)
|
||||
for objective in self.blueprint.config_json.get('objectives', []):
|
||||
self.assertIn(objective, prompt)
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# API Tests - Final Implementation Summary
|
||||
|
||||
## ✅ Section 1: Testing - COMPLETE
|
||||
|
||||
**Date Completed**: 2025-11-16
|
||||
**Status**: All Unit Tests Passing ✅
|
||||
|
||||
## Test Execution Results
|
||||
|
||||
### Unit Tests - ALL PASSING ✅
|
||||
|
||||
1. **test_response.py** - ✅ 16/16 tests passing
|
||||
- Tests all response helper functions
|
||||
- Verifies unified response format
|
||||
- Tests request ID generation
|
||||
|
||||
2. **test_permissions.py** - ✅ 20/20 tests passing
|
||||
- Tests all permission classes
|
||||
- Verifies role-based access control
|
||||
- Tests tenant isolation and bypass logic
|
||||
|
||||
3. **test_throttles.py** - ✅ 11/11 tests passing
|
||||
- Tests rate limiting logic
|
||||
- Verifies bypass mechanisms
|
||||
- Tests rate parsing
|
||||
|
||||
4. **test_exception_handler.py** - ✅ Ready (imports fixed)
|
||||
- Tests custom exception handler
|
||||
- Verifies unified error format
|
||||
- Tests all exception types
|
||||
|
||||
**Total Unit Tests**: 61 tests - ALL PASSING ✅
|
||||
|
||||
## Integration Tests Status
|
||||
|
||||
Integration tests have been created and are functional. Some tests may show failures due to:
|
||||
- Rate limiting (429 responses) - Tests updated to handle this
|
||||
- Endpoint availability in test environment
|
||||
- Test data requirements
|
||||
|
||||
**Note**: Integration tests verify unified API format regardless of endpoint status.
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
1. ✅ Fixed `RequestFactory` import (from `django.test` not `rest_framework.test`)
|
||||
2. ✅ Fixed Account creation to require `owner` field
|
||||
3. ✅ Fixed migration issues (0009_fix_admin_log_user_fk, 0006_alter_systemstatus)
|
||||
4. ✅ Updated integration tests to handle rate limiting (429 responses)
|
||||
5. ✅ Fixed system account creation in permission tests
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- ✅ Response Helpers: 100%
|
||||
- ✅ Exception Handler: 100%
|
||||
- ✅ Permissions: 100%
|
||||
- ✅ Rate Limiting: 100%
|
||||
- ✅ Integration Tests: Created for all modules
|
||||
|
||||
## Files Created
|
||||
|
||||
1. `test_response.py` - Response helper tests
|
||||
2. `test_exception_handler.py` - Exception handler tests
|
||||
3. `test_permissions.py` - Permission class tests
|
||||
4. `test_throttles.py` - Rate limiting tests
|
||||
5. `test_integration_base.py` - Base class for integration tests
|
||||
6. `test_integration_planner.py` - Planner module tests
|
||||
7. `test_integration_writer.py` - Writer module tests
|
||||
8. `test_integration_system.py` - System module tests
|
||||
9. `test_integration_billing.py` - Billing module tests
|
||||
10. `test_integration_auth.py` - Auth module tests
|
||||
11. `test_integration_errors.py` - Error scenario tests
|
||||
12. `test_integration_pagination.py` - Pagination tests
|
||||
13. `test_integration_rate_limiting.py` - Rate limiting integration tests
|
||||
14. `README.md` - Test documentation
|
||||
15. `TEST_SUMMARY.md` - Test statistics
|
||||
16. `run_tests.py` - Test runner script
|
||||
|
||||
## Verification
|
||||
|
||||
All unit tests have been executed and verified:
|
||||
```bash
|
||||
python manage.py test igny8_core.api.tests.test_response igny8_core.api.tests.test_permissions igny8_core.api.tests.test_throttles
|
||||
```
|
||||
|
||||
**Result**: ✅ ALL PASSING
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Unit tests ready for CI/CD
|
||||
2. ⚠️ Integration tests may need environment-specific configuration
|
||||
3. ✅ Changelog updated with testing section
|
||||
4. ✅ All test files documented
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Section 1: Testing is COMPLETE** ✅
|
||||
|
||||
All unit tests are passing and verify the Unified API Standard v1.0 implementation. Integration tests are created and functional, with appropriate handling for real-world API conditions (rate limiting, endpoint availability).
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# API Tests
|
||||
|
||||
This directory contains comprehensive unit and integration tests for the Unified API Standard v1.0.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests
|
||||
- `test_response.py` - Tests for response helper functions (success_response, error_response, paginated_response)
|
||||
- `test_exception_handler.py` - Tests for custom exception handler
|
||||
- `test_permissions.py` - Tests for permission classes
|
||||
- `test_throttles.py` - Tests for rate limiting
|
||||
|
||||
### Integration Tests
|
||||
- `test_integration_base.py` - Base class with common fixtures
|
||||
- `test_integration_planner.py` - Planner module endpoint tests
|
||||
- `test_integration_writer.py` - Writer module endpoint tests
|
||||
- `test_integration_system.py` - System module endpoint tests
|
||||
- `test_integration_billing.py` - Billing module endpoint tests
|
||||
- `test_integration_auth.py` - Auth module endpoint tests
|
||||
- `test_integration_errors.py` - Error scenario tests (400, 401, 403, 404, 429, 500)
|
||||
- `test_integration_pagination.py` - Pagination tests across all modules
|
||||
- `test_integration_rate_limiting.py` - Rate limiting integration tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
python manage.py test igny8_core.api.tests --verbosity=2
|
||||
```
|
||||
|
||||
### Run Specific Test File
|
||||
```bash
|
||||
python manage.py test igny8_core.api.tests.test_response
|
||||
python manage.py test igny8_core.api.tests.test_integration_planner
|
||||
```
|
||||
|
||||
### Run Specific Test Class
|
||||
```bash
|
||||
python manage.py test igny8_core.api.tests.test_response.ResponseHelpersTestCase
|
||||
```
|
||||
|
||||
### Run Specific Test Method
|
||||
```bash
|
||||
python manage.py test igny8_core.api.tests.test_response.ResponseHelpersTestCase.test_success_response_with_data
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Unit Tests Coverage
|
||||
- ✅ Response helpers (100%)
|
||||
- ✅ Exception handler (100%)
|
||||
- ✅ Permissions (100%)
|
||||
- ✅ Rate limiting (100%)
|
||||
|
||||
### Integration Tests Coverage
|
||||
- ✅ Planner module CRUD + AI actions
|
||||
- ✅ Writer module CRUD + AI actions
|
||||
- ✅ System module endpoints
|
||||
- ✅ Billing module endpoints
|
||||
- ✅ Auth module endpoints
|
||||
- ✅ Error scenarios (400, 401, 403, 404, 429, 500)
|
||||
- ✅ Pagination across all modules
|
||||
- ✅ Rate limiting headers and bypass logic
|
||||
|
||||
## Test Requirements
|
||||
|
||||
All tests verify:
|
||||
1. **Unified Response Format**: All endpoints return `{success, data/results, message, errors, request_id}`
|
||||
2. **Proper Status Codes**: Correct HTTP status codes (200, 201, 400, 401, 403, 404, 429, 500)
|
||||
3. **Error Format**: Error responses include `error`, `errors`, and `request_id`
|
||||
4. **Pagination Format**: Paginated responses include `success`, `count`, `next`, `previous`, `results`
|
||||
5. **Request ID**: All responses include `request_id` for tracking
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# API Tests - Execution Results
|
||||
|
||||
## Test Execution Summary
|
||||
|
||||
**Date**: 2025-11-16
|
||||
**Environment**: Docker Container (igny8_backend)
|
||||
**Database**: test_igny8_db
|
||||
|
||||
## Unit Tests Status
|
||||
|
||||
### ✅ test_response.py
|
||||
- **Status**: ✅ ALL PASSING (16/16)
|
||||
- **Coverage**: Response helpers (success_response, error_response, paginated_response, get_request_id)
|
||||
- **Result**: All tests verify unified response format correctly
|
||||
|
||||
### ✅ test_throttles.py
|
||||
- **Status**: ✅ ALL PASSING (11/11)
|
||||
- **Coverage**: Rate limiting logic, bypass mechanisms, rate parsing
|
||||
- **Result**: All throttle tests pass
|
||||
|
||||
### ⚠️ test_permissions.py
|
||||
- **Status**: ⚠️ 1 ERROR (18/19 passing)
|
||||
- **Issue**: System account creation in test_has_tenant_access_system_account
|
||||
- **Fix Applied**: Updated to create owner before account
|
||||
- **Note**: Needs re-run to verify fix
|
||||
|
||||
### ⚠️ test_exception_handler.py
|
||||
- **Status**: ⚠️ NEEDS VERIFICATION
|
||||
- **Issue**: Import error fixed (RequestFactory from django.test)
|
||||
- **Note**: Tests need to be run to verify all pass
|
||||
|
||||
## Integration Tests Status
|
||||
|
||||
### ⚠️ Integration Tests
|
||||
- **Status**: ⚠️ PARTIAL (Many failures due to rate limiting and endpoint availability)
|
||||
- **Issues**:
|
||||
1. Rate limiting (429 errors) - Tests updated to accept 429 as valid unified format
|
||||
2. Some endpoints may not exist or return different status codes
|
||||
3. Tests need to be more resilient to handle real API conditions
|
||||
|
||||
### Fixes Applied
|
||||
1. ✅ Updated integration tests to accept 429 (rate limited) as valid response
|
||||
2. ✅ Fixed Account creation to require owner
|
||||
3. ✅ Fixed RequestFactory import
|
||||
4. ✅ Fixed migration issues (0009, 0006)
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Test Files**: 13
|
||||
- **Total Test Methods**: ~115
|
||||
- **Unit Tests Passing**: 45/46 (98%)
|
||||
- **Integration Tests**: Needs refinement for production environment
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Unit tests are production-ready (response, throttles)
|
||||
2. ⚠️ Fix remaining permission test error
|
||||
3. ⚠️ Make integration tests more resilient:
|
||||
- Accept 404/429 as valid responses (still test unified format)
|
||||
- Skip tests if endpoints don't exist
|
||||
- Add retry logic for rate-limited requests
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Unit Tests**: Ready for CI/CD integration
|
||||
2. **Integration Tests**: Should be run in staging environment with proper test data
|
||||
3. **Rate Limiting**: Consider disabling for test environment or using higher limits
|
||||
4. **Test Data**: Ensure test database has proper fixtures for integration tests
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# API Tests - Implementation Summary
|
||||
|
||||
## Overview
|
||||
Comprehensive test suite for Unified API Standard v1.0 implementation covering all unit and integration tests.
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Unit Tests (4 files)
|
||||
1. **test_response.py** (153 lines)
|
||||
- Tests for `success_response()`, `error_response()`, `paginated_response()`
|
||||
- Tests for `get_request_id()`
|
||||
- 18 test methods covering all response scenarios
|
||||
|
||||
2. **test_exception_handler.py** (177 lines)
|
||||
- Tests for `custom_exception_handler()`
|
||||
- Tests all exception types (ValidationError, AuthenticationFailed, PermissionDenied, NotFound, Throttled, etc.)
|
||||
- Tests debug mode behavior
|
||||
- 12 test methods
|
||||
|
||||
3. **test_permissions.py** (245 lines)
|
||||
- Tests for `IsAuthenticatedAndActive`, `HasTenantAccess`, `IsViewerOrAbove`, `IsEditorOrAbove`, `IsAdminOrOwner`
|
||||
- Tests role-based access control
|
||||
- Tests tenant isolation
|
||||
- Tests admin/system account bypass
|
||||
- 20 test methods
|
||||
|
||||
4. **test_throttles.py** (145 lines)
|
||||
- Tests for `DebugScopedRateThrottle`
|
||||
- Tests bypass logic (DEBUG mode, env flag, admin/system accounts)
|
||||
- Tests rate parsing
|
||||
- 11 test methods
|
||||
|
||||
### Integration Tests (9 files)
|
||||
1. **test_integration_base.py** (107 lines)
|
||||
- Base test class with common fixtures
|
||||
- Helper methods: `assert_unified_response_format()`, `assert_paginated_response()`
|
||||
- Sets up: User, Account, Plan, Site, Sector, Industry, SeedKeyword
|
||||
|
||||
2. **test_integration_planner.py** (120 lines)
|
||||
- Tests Planner module endpoints (keywords, clusters, ideas)
|
||||
- Tests CRUD operations
|
||||
- Tests AI actions (auto_cluster)
|
||||
- Tests error scenarios
|
||||
- 12 test methods
|
||||
|
||||
3. **test_integration_writer.py** (65 lines)
|
||||
- Tests Writer module endpoints (tasks, content, images)
|
||||
- Tests CRUD operations
|
||||
- Tests error scenarios
|
||||
- 6 test methods
|
||||
|
||||
4. **test_integration_system.py** (50 lines)
|
||||
- Tests System module endpoints (status, prompts, settings, integrations)
|
||||
- 5 test methods
|
||||
|
||||
5. **test_integration_billing.py** (50 lines)
|
||||
- Tests Billing module endpoints (credits, usage, transactions)
|
||||
- 5 test methods
|
||||
|
||||
6. **test_integration_auth.py** (100 lines)
|
||||
- Tests Auth module endpoints (login, register, users, accounts, sites)
|
||||
- Tests authentication flows
|
||||
- Tests error scenarios
|
||||
- 8 test methods
|
||||
|
||||
7. **test_integration_errors.py** (95 lines)
|
||||
- Tests error scenarios (400, 401, 403, 404, 429, 500)
|
||||
- Tests unified error format
|
||||
- 6 test methods
|
||||
|
||||
8. **test_integration_pagination.py** (100 lines)
|
||||
- Tests pagination across all modules
|
||||
- Tests page size, page parameter, max page size
|
||||
- Tests empty results
|
||||
- 10 test methods
|
||||
|
||||
9. **test_integration_rate_limiting.py** (120 lines)
|
||||
- Tests rate limiting headers
|
||||
- Tests bypass logic (admin, system account, DEBUG mode)
|
||||
- Tests different throttle scopes
|
||||
- 7 test methods
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Test Files**: 13
|
||||
- **Total Test Methods**: ~115
|
||||
- **Total Lines of Code**: ~1,500
|
||||
- **Coverage**: 100% of API Standard components
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Unit Tests
|
||||
- ✅ Response Helpers (100%)
|
||||
- ✅ Exception Handler (100%)
|
||||
- ✅ Permissions (100%)
|
||||
- ✅ Rate Limiting (100%)
|
||||
|
||||
### Integration Tests
|
||||
- ✅ Planner Module (100%)
|
||||
- ✅ Writer Module (100%)
|
||||
- ✅ System Module (100%)
|
||||
- ✅ Billing Module (100%)
|
||||
- ✅ Auth Module (100%)
|
||||
- ✅ Error Scenarios (100%)
|
||||
- ✅ Pagination (100%)
|
||||
- ✅ Rate Limiting (100%)
|
||||
|
||||
## What Tests Verify
|
||||
|
||||
1. **Unified Response Format**
|
||||
- All responses include `success` field
|
||||
- Success responses include `data` or `results`
|
||||
- Error responses include `error` and `errors`
|
||||
- All responses include `request_id`
|
||||
|
||||
2. **Status Codes**
|
||||
- Correct HTTP status codes (200, 201, 400, 401, 403, 404, 429, 500)
|
||||
- Proper error messages for each status code
|
||||
|
||||
3. **Pagination**
|
||||
- Paginated responses include `count`, `next`, `previous`, `results`
|
||||
- Page size limits enforced
|
||||
- Empty results handled correctly
|
||||
|
||||
4. **Error Handling**
|
||||
- All exceptions wrapped in unified format
|
||||
- Field-specific errors included
|
||||
- Debug info in DEBUG mode
|
||||
|
||||
5. **Permissions**
|
||||
- Role-based access control
|
||||
- Tenant isolation
|
||||
- Admin/system account bypass
|
||||
|
||||
6. **Rate Limiting**
|
||||
- Throttle headers present
|
||||
- Bypass logic for admin/system accounts
|
||||
- Bypass in DEBUG mode
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python manage.py test igny8_core.api.tests --verbosity=2
|
||||
|
||||
# Run specific test file
|
||||
python manage.py test igny8_core.api.tests.test_response
|
||||
|
||||
# Run specific test class
|
||||
python manage.py test igny8_core.api.tests.test_response.ResponseHelpersTestCase
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run tests in Docker environment
|
||||
2. Verify all tests pass
|
||||
3. Add to CI/CD pipeline
|
||||
4. Monitor test coverage
|
||||
5. Add performance tests if needed
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
API Tests Package
|
||||
Unit and integration tests for unified API standard
|
||||
"""
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""
|
||||
Unit tests for AI framework
|
||||
Tests get_model_config() and AICore.run_ai_request() functions
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.auth.models import Account, User, Plan
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.ai.settings import get_model_config
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
|
||||
|
||||
class GetModelConfigTestCase(TestCase):
|
||||
"""Test cases for get_model_config() function"""
|
||||
|
||||
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,
|
||||
status='active'
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
def test_get_model_config_with_valid_settings(self):
|
||||
"""Test get_model_config() with valid IntegrationSettings"""
|
||||
IntegrationSettings.objects.create(
|
||||
integration_type='openai',
|
||||
account=self.account,
|
||||
is_active=True,
|
||||
config={
|
||||
'model': 'gpt-4o',
|
||||
'max_tokens': 4000,
|
||||
'temperature': 0.7,
|
||||
'apiKey': 'test-key'
|
||||
}
|
||||
)
|
||||
|
||||
config = get_model_config('auto_cluster', self.account)
|
||||
|
||||
self.assertEqual(config['model'], 'gpt-4o')
|
||||
self.assertEqual(config['max_tokens'], 4000)
|
||||
self.assertEqual(config['temperature'], 0.7)
|
||||
self.assertIn('response_format', config)
|
||||
|
||||
def test_get_model_config_without_account(self):
|
||||
"""Test get_model_config() without account - should raise ValueError"""
|
||||
with self.assertRaises(ValueError) as context:
|
||||
get_model_config('auto_cluster', None)
|
||||
|
||||
self.assertIn('Account is required', str(context.exception))
|
||||
|
||||
def test_get_model_config_without_integration_settings(self):
|
||||
"""Test get_model_config() without IntegrationSettings - should raise ValueError"""
|
||||
with self.assertRaises(ValueError) as context:
|
||||
get_model_config('auto_cluster', self.account)
|
||||
|
||||
self.assertIn('OpenAI IntegrationSettings not configured', str(context.exception))
|
||||
self.assertIn(str(self.account.id), str(context.exception))
|
||||
|
||||
def test_get_model_config_without_model_in_config(self):
|
||||
"""Test get_model_config() without model in config - should raise ValueError"""
|
||||
IntegrationSettings.objects.create(
|
||||
integration_type='openai',
|
||||
account=self.account,
|
||||
is_active=True,
|
||||
config={
|
||||
'max_tokens': 4000,
|
||||
'temperature': 0.7,
|
||||
'apiKey': 'test-key'
|
||||
# No 'model' key
|
||||
}
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
get_model_config('auto_cluster', self.account)
|
||||
|
||||
self.assertIn('Model not configured in IntegrationSettings', str(context.exception))
|
||||
self.assertIn(str(self.account.id), str(context.exception))
|
||||
|
||||
def test_get_model_config_with_inactive_settings(self):
|
||||
"""Test get_model_config() with inactive IntegrationSettings - should raise ValueError"""
|
||||
IntegrationSettings.objects.create(
|
||||
integration_type='openai',
|
||||
account=self.account,
|
||||
is_active=False,
|
||||
config={
|
||||
'model': 'gpt-4o',
|
||||
'max_tokens': 4000,
|
||||
'temperature': 0.7
|
||||
}
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError) as context:
|
||||
get_model_config('auto_cluster', self.account)
|
||||
|
||||
self.assertIn('OpenAI IntegrationSettings not configured', str(context.exception))
|
||||
|
||||
def test_get_model_config_with_function_alias(self):
|
||||
"""Test get_model_config() with function alias"""
|
||||
IntegrationSettings.objects.create(
|
||||
integration_type='openai',
|
||||
account=self.account,
|
||||
is_active=True,
|
||||
config={
|
||||
'model': 'gpt-4o-mini',
|
||||
'max_tokens': 2000,
|
||||
'temperature': 0.5
|
||||
}
|
||||
)
|
||||
|
||||
# Test with alias
|
||||
config1 = get_model_config('cluster_keywords', self.account)
|
||||
config2 = get_model_config('auto_cluster', self.account)
|
||||
|
||||
# Both should return the same config
|
||||
self.assertEqual(config1['model'], config2['model'])
|
||||
self.assertEqual(config1['model'], 'gpt-4o-mini')
|
||||
|
||||
def test_get_model_config_json_mode_models(self):
|
||||
"""Test get_model_config() sets response_format for JSON mode models"""
|
||||
json_models = ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo-preview', 'gpt-5.1', 'gpt-5.2']
|
||||
|
||||
for model in json_models:
|
||||
IntegrationSettings.objects.filter(account=self.account).delete()
|
||||
IntegrationSettings.objects.create(
|
||||
integration_type='openai',
|
||||
account=self.account,
|
||||
is_active=True,
|
||||
config={
|
||||
'model': model,
|
||||
'max_tokens': 4000,
|
||||
'temperature': 0.7
|
||||
}
|
||||
)
|
||||
|
||||
config = get_model_config('auto_cluster', self.account)
|
||||
self.assertIn('response_format', config)
|
||||
self.assertEqual(config['response_format'], {'type': 'json_object'})
|
||||
|
||||
|
||||
class AICoreTestCase(TestCase):
|
||||
"""Test cases for AICore.run_ai_request() function"""
|
||||
|
||||
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,
|
||||
status='active'
|
||||
)
|
||||
|
||||
# Update user to have account
|
||||
self.user.account = self.account
|
||||
self.user.save()
|
||||
|
||||
self.ai_core = AICore(account=self.account)
|
||||
|
||||
def test_run_ai_request_without_model(self):
|
||||
"""Test run_ai_request() without model - should return error dict"""
|
||||
result = self.ai_core.run_ai_request(
|
||||
prompt="Test prompt",
|
||||
model=None,
|
||||
function_name='test_function'
|
||||
)
|
||||
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('Model is required', result['error'])
|
||||
self.assertEqual(result['content'], None)
|
||||
self.assertEqual(result['total_tokens'], 0)
|
||||
|
||||
def test_run_ai_request_with_empty_model(self):
|
||||
"""Test run_ai_request() with empty model string - should return error dict"""
|
||||
result = self.ai_core.run_ai_request(
|
||||
prompt="Test prompt",
|
||||
model="",
|
||||
function_name='test_function'
|
||||
)
|
||||
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('Model is required', result['error'])
|
||||
self.assertEqual(result['content'], None)
|
||||
self.assertEqual(result['total_tokens'], 0)
|
||||
|
||||
def test_get_model_deprecated(self):
|
||||
"""Test get_model() method is deprecated and raises ValueError"""
|
||||
with self.assertRaises(ValueError) as context:
|
||||
self.ai_core.get_model('openai')
|
||||
|
||||
self.assertIn('deprecated', str(context.exception).lower())
|
||||
self.assertIn('run_ai_request', str(context.exception))
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Unit tests for custom exception handler
|
||||
Tests all exception types and status code mappings
|
||||
"""
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.http import HttpRequest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import (
|
||||
ValidationError, AuthenticationFailed, PermissionDenied, NotFound,
|
||||
MethodNotAllowed, NotAcceptable, Throttled
|
||||
)
|
||||
from rest_framework.views import APIView
|
||||
from igny8_core.api.exception_handlers import custom_exception_handler
|
||||
|
||||
|
||||
class ExceptionHandlerTestCase(TestCase):
|
||||
"""Test cases for custom exception handler"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.factory = RequestFactory()
|
||||
self.view = APIView()
|
||||
|
||||
def test_validation_error_400(self):
|
||||
"""Test ValidationError returns 400 with unified format"""
|
||||
request = self.factory.post('/test/', {})
|
||||
exc = ValidationError({"field": ["This field is required"]})
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('errors', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_authentication_failed_401(self):
|
||||
"""Test AuthenticationFailed returns 401 with unified format"""
|
||||
request = self.factory.get('/test/')
|
||||
exc = AuthenticationFailed("Authentication required")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], 'Authentication required')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_permission_denied_403(self):
|
||||
"""Test PermissionDenied returns 403 with unified format"""
|
||||
request = self.factory.get('/test/')
|
||||
exc = PermissionDenied("Permission denied")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], 'Permission denied')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_not_found_404(self):
|
||||
"""Test NotFound returns 404 with unified format"""
|
||||
request = self.factory.get('/test/')
|
||||
exc = NotFound("Resource not found")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], 'Resource not found')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_throttled_429(self):
|
||||
"""Test Throttled returns 429 with unified format"""
|
||||
request = self.factory.get('/test/')
|
||||
exc = Throttled()
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], 'Rate limit exceeded')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_method_not_allowed_405(self):
|
||||
"""Test MethodNotAllowed returns 405 with unified format"""
|
||||
request = self.factory.post('/test/')
|
||||
exc = MethodNotAllowed("POST")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_unhandled_exception_500(self):
|
||||
"""Test unhandled exception returns 500 with unified format"""
|
||||
request = self.factory.get('/test/')
|
||||
exc = ValueError("Unexpected error")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], 'Internal server error')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_exception_handler_includes_request_id(self):
|
||||
"""Test exception handler includes request_id in response"""
|
||||
request = self.factory.get('/test/')
|
||||
request.request_id = 'test-request-id-exception'
|
||||
exc = ValidationError("Test error")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertIn('request_id', response.data)
|
||||
self.assertEqual(response.data['request_id'], 'test-request-id-exception')
|
||||
|
||||
def test_exception_handler_debug_mode(self):
|
||||
"""Test exception handler includes debug info in DEBUG mode"""
|
||||
from django.conf import settings
|
||||
original_debug = settings.DEBUG
|
||||
|
||||
try:
|
||||
settings.DEBUG = True
|
||||
request = self.factory.get('/test/')
|
||||
exc = ValueError("Test error")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertIn('debug', response.data)
|
||||
self.assertIn('exception_type', response.data['debug'])
|
||||
self.assertIn('exception_message', response.data['debug'])
|
||||
self.assertIn('view', response.data['debug'])
|
||||
self.assertIn('path', response.data['debug'])
|
||||
self.assertIn('method', response.data['debug'])
|
||||
finally:
|
||||
settings.DEBUG = original_debug
|
||||
|
||||
def test_exception_handler_no_debug_mode(self):
|
||||
"""Test exception handler excludes debug info when DEBUG=False"""
|
||||
from django.conf import settings
|
||||
original_debug = settings.DEBUG
|
||||
|
||||
try:
|
||||
settings.DEBUG = False
|
||||
request = self.factory.get('/test/')
|
||||
exc = ValueError("Test error")
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertNotIn('debug', response.data)
|
||||
finally:
|
||||
settings.DEBUG = original_debug
|
||||
|
||||
def test_field_specific_validation_errors(self):
|
||||
"""Test field-specific validation errors are included"""
|
||||
request = self.factory.post('/test/', {})
|
||||
exc = ValidationError({
|
||||
"email": ["Invalid email format"],
|
||||
"password": ["Password too short", "Password must contain numbers"]
|
||||
})
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertIn('errors', response.data)
|
||||
self.assertIn('email', response.data['errors'])
|
||||
self.assertIn('password', response.data['errors'])
|
||||
self.assertEqual(len(response.data['errors']['password']), 2)
|
||||
|
||||
def test_non_field_validation_errors(self):
|
||||
"""Test non-field validation errors are handled"""
|
||||
request = self.factory.post('/test/', {})
|
||||
exc = ValidationError({"non_field_errors": ["General validation error"]})
|
||||
context = {'request': request, 'view': self.view}
|
||||
|
||||
response = custom_exception_handler(exc, context)
|
||||
|
||||
self.assertIn('errors', response.data)
|
||||
self.assertIn('non_field_errors', response.data['errors'])
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
Integration tests for Auth module endpoints
|
||||
Tests login, register, user management return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
|
||||
|
||||
class AuthIntegrationTestCase(TestCase):
|
||||
"""Integration tests for Auth module"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.client = APIClient()
|
||||
|
||||
# Create test plan and account
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create test user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create test 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()
|
||||
|
||||
def assert_unified_response_format(self, response, expected_success=True):
|
||||
"""Assert response follows unified format"""
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['success'], expected_success)
|
||||
|
||||
if expected_success:
|
||||
self.assertTrue('data' in response.data or 'results' in response.data)
|
||||
else:
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_login_returns_unified_format(self):
|
||||
"""Test POST /api/v1/auth/login/ returns unified format"""
|
||||
data = {
|
||||
'email': 'test@test.com',
|
||||
'password': 'testpass123'
|
||||
}
|
||||
response = self.client.post('/api/v1/auth/login/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
self.assertIn('user', response.data['data'])
|
||||
self.assertIn('access', response.data['data'])
|
||||
|
||||
def test_login_invalid_credentials_returns_unified_format(self):
|
||||
"""Test login with invalid credentials returns unified format"""
|
||||
data = {
|
||||
'email': 'test@test.com',
|
||||
'password': 'wrongpassword'
|
||||
}
|
||||
response = self.client.post('/api/v1/auth/login/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_register_returns_unified_format(self):
|
||||
"""Test POST /api/v1/auth/register/ returns unified format"""
|
||||
data = {
|
||||
'email': 'newuser@test.com',
|
||||
'username': 'newuser',
|
||||
'password': 'testpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
}
|
||||
response = self.client.post('/api/v1/auth/register/', data, format='json')
|
||||
|
||||
# May return 400 if validation fails, but should still be unified format
|
||||
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST])
|
||||
self.assert_unified_response_format(response)
|
||||
|
||||
def test_list_users_returns_unified_format(self):
|
||||
"""Test GET /api/v1/auth/users/ returns unified format"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
response = self.client.get('/api/v1/auth/users/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_list_accounts_returns_unified_format(self):
|
||||
"""Test GET /api/v1/auth/accounts/ returns unified format"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
response = self.client.get('/api/v1/auth/accounts/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_list_sites_returns_unified_format(self):
|
||||
"""Test GET /api/v1/auth/sites/ returns unified format"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
response = self.client.get('/api/v1/auth/sites/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_unauthorized_returns_unified_format(self):
|
||||
"""Test 401 errors return unified format"""
|
||||
# Don't authenticate
|
||||
response = self.client.get('/api/v1/auth/users/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
Base test class for integration tests
|
||||
Provides common fixtures and utilities
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from igny8_core.auth.models import User, Account, Plan, Site, Sector, Industry, IndustrySector, SeedKeyword
|
||||
|
||||
|
||||
class IntegrationTestBase(TestCase):
|
||||
"""Base class for integration tests with common fixtures"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.client = APIClient()
|
||||
|
||||
# Create test plan
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create test user first (Account needs owner)
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create test 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"
|
||||
)
|
||||
|
||||
# Create site
|
||||
self.site = Site.objects.create(
|
||||
name="Test Site",
|
||||
slug="test-site",
|
||||
account=self.account,
|
||||
industry=self.industry
|
||||
)
|
||||
|
||||
# Create sector (Sector needs industry_sector reference)
|
||||
self.sector = Sector.objects.create(
|
||||
name="Test Sector",
|
||||
slug="test-sector",
|
||||
site=self.site,
|
||||
account=self.account,
|
||||
industry_sector=self.industry_sector
|
||||
)
|
||||
|
||||
# Create seed keyword
|
||||
self.seed_keyword = SeedKeyword.objects.create(
|
||||
keyword="test keyword",
|
||||
industry=self.industry,
|
||||
sector=self.industry_sector,
|
||||
volume=1000,
|
||||
difficulty=50,
|
||||
country="US"
|
||||
)
|
||||
|
||||
# Authenticate client
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Set account on request (simulating middleware)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def assert_unified_response_format(self, response, expected_success=True):
|
||||
"""Assert response follows unified format"""
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['success'], expected_success)
|
||||
|
||||
if expected_success:
|
||||
# Success responses should have data or results
|
||||
self.assertTrue('data' in response.data or 'results' in response.data)
|
||||
else:
|
||||
# Error responses should have error
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def assert_paginated_response(self, response):
|
||||
"""Assert response is a paginated response"""
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('count', response.data)
|
||||
self.assertIn('results', response.data)
|
||||
self.assertIn('next', response.data)
|
||||
self.assertIn('previous', response.data)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
Integration tests for Billing module endpoints
|
||||
Tests credit balance, usage, transactions return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class BillingIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for Billing module"""
|
||||
|
||||
def test_credit_balance_returns_unified_format(self):
|
||||
"""Test GET /api/v1/billing/credits/balance/balance/ returns unified format"""
|
||||
response = self.client.get('/api/v1/billing/credits/balance/balance/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_credit_usage_returns_unified_format(self):
|
||||
"""Test GET /api/v1/billing/credits/usage/ returns unified format"""
|
||||
response = self.client.get('/api/v1/billing/credits/usage/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_usage_summary_returns_unified_format(self):
|
||||
"""Test GET /api/v1/billing/credits/usage/summary/ returns unified format"""
|
||||
response = self.client.get('/api/v1/billing/credits/usage/summary/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_usage_limits_returns_unified_format(self):
|
||||
"""Test GET /api/v1/billing/credits/usage/limits/ returns unified format"""
|
||||
response = self.client.get('/api/v1/billing/credits/usage/limits/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_transactions_returns_unified_format(self):
|
||||
"""Test GET /api/v1/billing/credits/transactions/ returns unified format"""
|
||||
response = self.client.get('/api/v1/billing/credits/transactions/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Integration tests for error scenarios
|
||||
Tests 400, 401, 403, 404, 429, 500 responses return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ErrorScenariosTestCase(IntegrationTestBase):
|
||||
"""Integration tests for error scenarios"""
|
||||
|
||||
def test_400_bad_request_returns_unified_format(self):
|
||||
"""Test 400 Bad Request returns unified format"""
|
||||
# Invalid data
|
||||
data = {'invalid': 'data'}
|
||||
response = self.client.post('/api/v1/planner/keywords/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('errors', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_401_unauthorized_returns_unified_format(self):
|
||||
"""Test 401 Unauthorized returns unified format"""
|
||||
# Create unauthenticated client
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get('/api/v1/planner/keywords/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertEqual(response.data['error'], 'Authentication required')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_403_forbidden_returns_unified_format(self):
|
||||
"""Test 403 Forbidden returns unified format"""
|
||||
# Create viewer user (limited permissions)
|
||||
viewer_user = User.objects.create_user(
|
||||
username='viewer',
|
||||
email='viewer@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
viewer_client = APIClient()
|
||||
viewer_client.force_authenticate(user=viewer_user)
|
||||
|
||||
# Try to access admin-only endpoint (if exists)
|
||||
# For now, test with a protected endpoint that requires editor+
|
||||
response = viewer_client.post('/api/v1/planner/keywords/auto_cluster/', {}, format='json')
|
||||
|
||||
# May return 400 (validation) or 403 (permission), both should be unified
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_403_FORBIDDEN])
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_404_not_found_returns_unified_format(self):
|
||||
"""Test 404 Not Found returns unified format"""
|
||||
response = self.client.get('/api/v1/planner/keywords/99999/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertEqual(response.data['error'], 'Resource not found')
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_404_invalid_endpoint_returns_unified_format(self):
|
||||
"""Test 404 for invalid endpoint returns unified format"""
|
||||
response = self.client.get('/api/v1/nonexistent/endpoint/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
# DRF may return different format for URL not found, but our handler should catch it
|
||||
if 'success' in response.data:
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
|
||||
def test_validation_error_returns_unified_format(self):
|
||||
"""Test validation errors return unified format with field-specific errors"""
|
||||
# Missing required fields
|
||||
response = self.client.post('/api/v1/planner/keywords/', {}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('errors', response.data)
|
||||
# Should have field-specific errors
|
||||
self.assertIsInstance(response.data['errors'], dict)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
Integration tests for pagination
|
||||
Tests paginated responses across all modules return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
from igny8_core.modules.planner.models import Keywords
|
||||
from igny8_core.auth.models import SeedKeyword, Industry, IndustrySector
|
||||
|
||||
|
||||
class PaginationIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for pagination"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures with multiple records"""
|
||||
super().setUp()
|
||||
|
||||
# Create multiple keywords for pagination testing
|
||||
for i in range(15):
|
||||
Keywords.objects.create(
|
||||
seed_keyword=self.seed_keyword,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
account=self.account,
|
||||
status='active'
|
||||
)
|
||||
|
||||
def test_pagination_default_page_size(self):
|
||||
"""Test pagination with default page size"""
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
self.assertEqual(response.data['count'], 15)
|
||||
self.assertLessEqual(len(response.data['results']), 10) # Default page size
|
||||
self.assertIsNotNone(response.data['next']) # Should have next page
|
||||
|
||||
def test_pagination_custom_page_size(self):
|
||||
"""Test pagination with custom page size"""
|
||||
response = self.client.get('/api/v1/planner/keywords/?page_size=5')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
self.assertEqual(response.data['count'], 15)
|
||||
self.assertEqual(len(response.data['results']), 5)
|
||||
self.assertIsNotNone(response.data['next'])
|
||||
|
||||
def test_pagination_page_parameter(self):
|
||||
"""Test pagination with page parameter"""
|
||||
response = self.client.get('/api/v1/planner/keywords/?page=2&page_size=5')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
self.assertEqual(response.data['count'], 15)
|
||||
self.assertEqual(len(response.data['results']), 5)
|
||||
self.assertIsNotNone(response.data['previous'])
|
||||
|
||||
def test_pagination_max_page_size(self):
|
||||
"""Test pagination respects max page size"""
|
||||
response = self.client.get('/api/v1/planner/keywords/?page_size=200') # Exceeds max of 100
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
self.assertLessEqual(len(response.data['results']), 100) # Should be capped at 100
|
||||
|
||||
def test_pagination_empty_results(self):
|
||||
"""Test pagination with empty results"""
|
||||
# Use a filter that returns no results
|
||||
response = self.client.get('/api/v1/planner/keywords/?status=nonexistent')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
self.assertEqual(response.data['count'], 0)
|
||||
self.assertEqual(len(response.data['results']), 0)
|
||||
self.assertIsNone(response.data['next'])
|
||||
self.assertIsNone(response.data['previous'])
|
||||
|
||||
def test_pagination_includes_success_field(self):
|
||||
"""Test paginated responses include success field"""
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('success', response.data)
|
||||
self.assertTrue(response.data['success'])
|
||||
|
||||
def test_pagination_clusters(self):
|
||||
"""Test pagination works for clusters endpoint"""
|
||||
response = self.client.get('/api/v1/planner/clusters/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_pagination_ideas(self):
|
||||
"""Test pagination works for ideas endpoint"""
|
||||
response = self.client.get('/api/v1/planner/ideas/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_pagination_tasks(self):
|
||||
"""Test pagination works for tasks endpoint"""
|
||||
response = self.client.get('/api/v1/writer/tasks/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_pagination_content(self):
|
||||
"""Test pagination works for content endpoint"""
|
||||
response = self.client.get('/api/v1/writer/content/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Integration tests for Planner module endpoints
|
||||
Tests CRUD operations and AI actions return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||
|
||||
|
||||
class PlannerIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for Planner module"""
|
||||
|
||||
def test_list_keywords_returns_unified_format(self):
|
||||
"""Test GET /api/v1/planner/keywords/ returns unified format"""
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
|
||||
# May get 429 if rate limited - both should have unified format
|
||||
if response.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
else:
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_create_keyword_returns_unified_format(self):
|
||||
"""Test POST /api/v1/planner/keywords/ returns unified format"""
|
||||
data = {
|
||||
'seed_keyword_id': self.seed_keyword.id,
|
||||
'site_id': self.site.id,
|
||||
'sector_id': self.sector.id,
|
||||
'status': 'active'
|
||||
}
|
||||
response = self.client.post('/api/v1/planner/keywords/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
self.assertIn('id', response.data['data'])
|
||||
|
||||
def test_retrieve_keyword_returns_unified_format(self):
|
||||
"""Test GET /api/v1/planner/keywords/{id}/ returns unified format"""
|
||||
keyword = Keywords.objects.create(
|
||||
seed_keyword=self.seed_keyword,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
account=self.account,
|
||||
status='active'
|
||||
)
|
||||
|
||||
response = self.client.get(f'/api/v1/planner/keywords/{keyword.id}/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
self.assertEqual(response.data['data']['id'], keyword.id)
|
||||
|
||||
def test_update_keyword_returns_unified_format(self):
|
||||
"""Test PUT /api/v1/planner/keywords/{id}/ returns unified format"""
|
||||
keyword = Keywords.objects.create(
|
||||
seed_keyword=self.seed_keyword,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
account=self.account,
|
||||
status='active'
|
||||
)
|
||||
|
||||
data = {
|
||||
'seed_keyword_id': self.seed_keyword.id,
|
||||
'site_id': self.site.id,
|
||||
'sector_id': self.sector.id,
|
||||
'status': 'archived'
|
||||
}
|
||||
response = self.client.put(f'/api/v1/planner/keywords/{keyword.id}/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_delete_keyword_returns_unified_format(self):
|
||||
"""Test DELETE /api/v1/planner/keywords/{id}/ returns unified format"""
|
||||
keyword = Keywords.objects.create(
|
||||
seed_keyword=self.seed_keyword,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
account=self.account,
|
||||
status='active'
|
||||
)
|
||||
|
||||
response = self.client.delete(f'/api/v1/planner/keywords/{keyword.id}/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def test_list_clusters_returns_unified_format(self):
|
||||
"""Test GET /api/v1/planner/clusters/ returns unified format"""
|
||||
response = self.client.get('/api/v1/planner/clusters/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_create_cluster_returns_unified_format(self):
|
||||
"""Test POST /api/v1/planner/clusters/ returns unified format"""
|
||||
data = {
|
||||
'name': 'Test Cluster',
|
||||
'description': 'Test description',
|
||||
'site_id': self.site.id,
|
||||
'sector_id': self.sector.id,
|
||||
'status': 'active'
|
||||
}
|
||||
response = self.client.post('/api/v1/planner/clusters/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_list_ideas_returns_unified_format(self):
|
||||
"""Test GET /api/v1/planner/ideas/ returns unified format"""
|
||||
response = self.client.get('/api/v1/planner/ideas/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_auto_cluster_returns_unified_format(self):
|
||||
"""Test POST /api/v1/planner/keywords/auto_cluster/ returns unified format"""
|
||||
keyword = Keywords.objects.create(
|
||||
seed_keyword=self.seed_keyword,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
account=self.account,
|
||||
status='active'
|
||||
)
|
||||
|
||||
data = {
|
||||
'ids': [keyword.id],
|
||||
'sector_id': self.sector.id
|
||||
}
|
||||
response = self.client.post('/api/v1/planner/keywords/auto_cluster/', data, format='json')
|
||||
|
||||
# Should return either task_id (async) or success response
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_202_ACCEPTED])
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
|
||||
def test_keyword_validation_error_returns_unified_format(self):
|
||||
"""Test validation errors return unified format"""
|
||||
# Missing required fields
|
||||
response = self.client.post('/api/v1/planner/keywords/', {}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('errors', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
def test_keyword_not_found_returns_unified_format(self):
|
||||
"""Test 404 errors return unified format"""
|
||||
response = self.client.get('/api/v1/planner/keywords/99999/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('request_id', response.data)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
Integration tests for rate limiting
|
||||
Tests throttle headers and 429 responses
|
||||
"""
|
||||
from rest_framework import status
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
|
||||
|
||||
class RateLimitingIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for rate limiting"""
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_throttle_headers_present(self):
|
||||
"""Test throttle headers are present in responses"""
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
|
||||
# May get 429 if rate limited, or 200 if bypassed - both are valid
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS])
|
||||
# Throttle headers should be present
|
||||
# Note: In test environment, throttling may be bypassed, but headers should still be set
|
||||
# We check if headers exist (they may not be set if throttling is bypassed in tests)
|
||||
if 'X-Throttle-Limit' in response:
|
||||
self.assertIn('X-Throttle-Limit', response)
|
||||
self.assertIn('X-Throttle-Remaining', response)
|
||||
self.assertIn('X-Throttle-Reset', response)
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_rate_limit_bypass_for_admin(self):
|
||||
"""Test rate limiting is bypassed for admin users"""
|
||||
# Create admin user
|
||||
admin_user = User.objects.create_user(
|
||||
username='admin',
|
||||
email='admin@test.com',
|
||||
password='testpass123',
|
||||
role='admin',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
admin_client = APIClient()
|
||||
admin_client.force_authenticate(user=admin_user)
|
||||
|
||||
# Make multiple requests - should not be throttled
|
||||
for i in range(15):
|
||||
response = admin_client.get('/api/v1/planner/keywords/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should not get 429
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_rate_limit_bypass_for_system_account(self):
|
||||
"""Test rate limiting is bypassed for system account users"""
|
||||
# Create system account
|
||||
system_account = Account.objects.create(
|
||||
name="AWS Admin",
|
||||
slug="aws-admin",
|
||||
plan=self.plan
|
||||
)
|
||||
|
||||
system_user = User.objects.create_user(
|
||||
username='system',
|
||||
email='system@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=system_account
|
||||
)
|
||||
|
||||
system_client = APIClient()
|
||||
system_client.force_authenticate(user=system_user)
|
||||
|
||||
# Make multiple requests - should not be throttled
|
||||
for i in range(15):
|
||||
response = system_client.get('/api/v1/planner/keywords/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should not get 429
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_rate_limit_bypass_in_debug_mode(self):
|
||||
"""Test rate limiting is bypassed in DEBUG mode"""
|
||||
# Make multiple requests - should not be throttled in DEBUG mode
|
||||
for i in range(15):
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should not get 429
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=True)
|
||||
def test_rate_limit_bypass_with_env_flag(self):
|
||||
"""Test rate limiting is bypassed when IGNY8_DEBUG_THROTTLE=True"""
|
||||
# Make multiple requests - should not be throttled
|
||||
for i in range(15):
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should not get 429
|
||||
|
||||
def test_different_throttle_scopes(self):
|
||||
"""Test different endpoints have different throttle scopes"""
|
||||
# Planner endpoints - may get 429 if rate limited
|
||||
response = self.client.get('/api/v1/planner/keywords/')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS])
|
||||
|
||||
# Writer endpoints - may get 429 if rate limited
|
||||
response = self.client.get('/api/v1/writer/tasks/')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS])
|
||||
|
||||
# System endpoints - may get 429 if rate limited
|
||||
response = self.client.get('/api/v1/system/prompts/')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS])
|
||||
|
||||
# Billing endpoints - may get 429 if rate limited
|
||||
response = self.client.get('/api/v1/billing/credits/balance/balance/')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_429_TOO_MANY_REQUESTS])
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
Integration tests for System module endpoints
|
||||
Tests settings, prompts, integrations return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class SystemIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for System module"""
|
||||
|
||||
def test_system_status_returns_unified_format(self):
|
||||
"""Test GET /api/v1/system/status/ returns unified format"""
|
||||
response = self.client.get('/api/v1/system/status/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_list_prompts_returns_unified_format(self):
|
||||
"""Test GET /api/v1/system/prompts/ returns unified format"""
|
||||
response = self.client.get('/api/v1/system/prompts/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_get_prompt_by_type_returns_unified_format(self):
|
||||
"""Test GET /api/v1/system/prompts/by_type/{type}/ returns unified format"""
|
||||
response = self.client.get('/api/v1/system/prompts/by_type/clustering/')
|
||||
|
||||
# May return 404 if no prompt exists, but should still be unified format
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
|
||||
self.assert_unified_response_format(response)
|
||||
|
||||
def test_list_account_settings_returns_unified_format(self):
|
||||
"""Test GET /api/v1/system/settings/account/ returns unified format"""
|
||||
response = self.client.get('/api/v1/system/settings/account/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_get_integration_settings_returns_unified_format(self):
|
||||
"""Test GET /api/v1/system/settings/integrations/{pk}/ returns unified format"""
|
||||
response = self.client.get('/api/v1/system/settings/integrations/openai/')
|
||||
|
||||
# May return 404 if not configured, but should still be unified format
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
|
||||
self.assert_unified_response_format(response)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Integration tests for Writer module endpoints
|
||||
Tests CRUD operations and AI actions return unified format
|
||||
"""
|
||||
from rest_framework import status
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
from igny8_core.modules.writer.models import Tasks, Content, Images
|
||||
|
||||
|
||||
class WriterIntegrationTestCase(IntegrationTestBase):
|
||||
"""Integration tests for Writer module"""
|
||||
|
||||
def test_list_tasks_returns_unified_format(self):
|
||||
"""Test GET /api/v1/writer/tasks/ returns unified format"""
|
||||
response = self.client.get('/api/v1/writer/tasks/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_create_task_returns_unified_format(self):
|
||||
"""Test POST /api/v1/writer/tasks/ returns unified format"""
|
||||
data = {
|
||||
'title': 'Test Task',
|
||||
'site_id': self.site.id,
|
||||
'sector_id': self.sector.id,
|
||||
'status': 'pending'
|
||||
}
|
||||
response = self.client.post('/api/v1/writer/tasks/', data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assert_unified_response_format(response, expected_success=True)
|
||||
self.assertIn('data', response.data)
|
||||
|
||||
def test_list_content_returns_unified_format(self):
|
||||
"""Test GET /api/v1/writer/content/ returns unified format"""
|
||||
response = self.client.get('/api/v1/writer/content/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_list_images_returns_unified_format(self):
|
||||
"""Test GET /api/v1/writer/images/ returns unified format"""
|
||||
response = self.client.get('/api/v1/writer/images/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assert_paginated_response(response)
|
||||
|
||||
def test_create_image_returns_unified_format(self):
|
||||
"""Test POST /api/v1/writer/images/ returns unified format"""
|
||||
data = {
|
||||
'image_type': 'featured',
|
||||
'site_id': self.site.id,
|
||||
'sector_id': self.sector.id,
|
||||
'status': 'pending'
|
||||
}
|
||||
response = self.client.post('/api/v1/writer/images/', data, format='json')
|
||||
|
||||
# May return 400 if site/sector validation fails, but should still be unified format
|
||||
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST])
|
||||
self.assert_unified_response_format(response)
|
||||
|
||||
def test_task_validation_error_returns_unified_format(self):
|
||||
"""Test validation errors return unified format"""
|
||||
response = self.client.post('/api/v1/writer/tasks/', {}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assert_unified_response_format(response, expected_success=False)
|
||||
self.assertIn('error', response.data)
|
||||
self.assertIn('errors', response.data)
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
"""
|
||||
Unit tests for permission classes
|
||||
Tests IsAuthenticatedAndActive, HasTenantAccess, IsViewerOrAbove, IsEditorOrAbove, IsAdminOrOwner
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
from igny8_core.api.permissions import (
|
||||
IsAuthenticatedAndActive, HasTenantAccess, IsViewerOrAbove,
|
||||
IsEditorOrAbove, IsAdminOrOwner
|
||||
)
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
|
||||
|
||||
class PermissionsTestCase(TestCase):
|
||||
"""Test cases for permission classes"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.factory = APIRequestFactory()
|
||||
self.view = APIView()
|
||||
|
||||
# Create test plan
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create owner user first (Account needs owner)
|
||||
self.owner_user = User.objects.create_user(
|
||||
username='owner',
|
||||
email='owner@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create test account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.owner_user
|
||||
)
|
||||
|
||||
# Update owner user to have account
|
||||
self.owner_user.account = self.account
|
||||
self.owner_user.save()
|
||||
|
||||
self.admin_user = User.objects.create_user(
|
||||
username='admin',
|
||||
email='admin@test.com',
|
||||
password='testpass123',
|
||||
role='admin',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
self.editor_user = User.objects.create_user(
|
||||
username='editor',
|
||||
email='editor@test.com',
|
||||
password='testpass123',
|
||||
role='editor',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
self.viewer_user = User.objects.create_user(
|
||||
username='viewer',
|
||||
email='viewer@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
# Create another account for tenant isolation testing
|
||||
self.other_owner = User.objects.create_user(
|
||||
username='other_owner',
|
||||
email='other_owner@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
self.other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other-account",
|
||||
plan=self.plan,
|
||||
owner=self.other_owner
|
||||
)
|
||||
|
||||
self.other_owner.account = self.other_account
|
||||
self.other_owner.save()
|
||||
|
||||
self.other_user = User.objects.create_user(
|
||||
username='other',
|
||||
email='other@test.com',
|
||||
password='testpass123',
|
||||
role='owner',
|
||||
account=self.other_account
|
||||
)
|
||||
|
||||
def test_is_authenticated_and_active_authenticated(self):
|
||||
"""Test IsAuthenticatedAndActive allows authenticated users"""
|
||||
permission = IsAuthenticatedAndActive()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_authenticated_and_active_unauthenticated(self):
|
||||
"""Test IsAuthenticatedAndActive denies unauthenticated users"""
|
||||
permission = IsAuthenticatedAndActive()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = None
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_is_authenticated_and_active_inactive_user(self):
|
||||
"""Test IsAuthenticatedAndActive denies inactive users"""
|
||||
permission = IsAuthenticatedAndActive()
|
||||
self.owner_user.is_active = False
|
||||
self.owner_user.save()
|
||||
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_has_tenant_access_same_account(self):
|
||||
"""Test HasTenantAccess allows users from same account"""
|
||||
permission = HasTenantAccess()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
request.account = self.account
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_has_tenant_access_different_account(self):
|
||||
"""Test HasTenantAccess denies users from different account"""
|
||||
permission = HasTenantAccess()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
request.account = self.other_account
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_has_tenant_access_admin_bypass(self):
|
||||
"""Test HasTenantAccess allows admin/developer to bypass"""
|
||||
permission = HasTenantAccess()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.admin_user
|
||||
request.account = self.other_account # Different account
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result) # Admin should bypass
|
||||
|
||||
def test_has_tenant_access_system_account(self):
|
||||
"""Test HasTenantAccess allows system account users to bypass"""
|
||||
# Create system account owner
|
||||
system_owner = User.objects.create_user(
|
||||
username='system_owner_test',
|
||||
email='system_owner_test@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create system account
|
||||
system_account = Account.objects.create(
|
||||
name="AWS Admin",
|
||||
slug="aws-admin",
|
||||
plan=self.plan,
|
||||
owner=system_owner
|
||||
)
|
||||
|
||||
system_owner.account = system_account
|
||||
system_owner.save()
|
||||
|
||||
system_user = User.objects.create_user(
|
||||
username='system',
|
||||
email='system@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=system_account
|
||||
)
|
||||
|
||||
permission = HasTenantAccess()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = system_user
|
||||
request.account = self.account # Different account
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result) # System account user should bypass
|
||||
|
||||
def test_is_viewer_or_above_viewer(self):
|
||||
"""Test IsViewerOrAbove allows viewer role"""
|
||||
permission = IsViewerOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.viewer_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_viewer_or_above_editor(self):
|
||||
"""Test IsViewerOrAbove allows editor role"""
|
||||
permission = IsViewerOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.editor_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_viewer_or_above_admin(self):
|
||||
"""Test IsViewerOrAbove allows admin role"""
|
||||
permission = IsViewerOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.admin_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_viewer_or_above_owner(self):
|
||||
"""Test IsViewerOrAbove allows owner role"""
|
||||
permission = IsViewerOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_editor_or_above_viewer_denied(self):
|
||||
"""Test IsEditorOrAbove denies viewer role"""
|
||||
permission = IsEditorOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.viewer_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_is_editor_or_above_editor_allowed(self):
|
||||
"""Test IsEditorOrAbove allows editor role"""
|
||||
permission = IsEditorOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.editor_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_editor_or_above_admin_allowed(self):
|
||||
"""Test IsEditorOrAbove allows admin role"""
|
||||
permission = IsEditorOrAbove()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.admin_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_admin_or_owner_viewer_denied(self):
|
||||
"""Test IsAdminOrOwner denies viewer role"""
|
||||
permission = IsAdminOrOwner()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.viewer_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_is_admin_or_owner_editor_denied(self):
|
||||
"""Test IsAdminOrOwner denies editor role"""
|
||||
permission = IsAdminOrOwner()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.editor_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_is_admin_or_owner_admin_allowed(self):
|
||||
"""Test IsAdminOrOwner allows admin role"""
|
||||
permission = IsAdminOrOwner()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.admin_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_is_admin_or_owner_owner_allowed(self):
|
||||
"""Test IsAdminOrOwner allows owner role"""
|
||||
permission = IsAdminOrOwner()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.owner_user
|
||||
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_all_permissions_unauthenticated_denied(self):
|
||||
"""Test all permissions deny unauthenticated users"""
|
||||
permissions = [
|
||||
IsAuthenticatedAndActive(),
|
||||
HasTenantAccess(),
|
||||
IsViewerOrAbove(),
|
||||
IsEditorOrAbove(),
|
||||
IsAdminOrOwner()
|
||||
]
|
||||
|
||||
request = self.factory.get('/test/')
|
||||
request.user = None
|
||||
|
||||
for permission in permissions:
|
||||
result = permission.has_permission(request, self.view)
|
||||
self.assertFalse(result, f"{permission.__class__.__name__} should deny unauthenticated users")
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Unit tests for response helper functions
|
||||
Tests success_response, error_response, paginated_response
|
||||
"""
|
||||
from django.test import TestCase, RequestFactory
|
||||
from rest_framework import status
|
||||
from igny8_core.api.response import success_response, error_response, paginated_response, get_request_id
|
||||
|
||||
|
||||
class ResponseHelpersTestCase(TestCase):
|
||||
"""Test cases for response helper functions"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_success_response_with_data(self):
|
||||
"""Test success_response with data"""
|
||||
data = {"id": 1, "name": "Test"}
|
||||
response = success_response(data=data, message="Success")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data'], data)
|
||||
self.assertEqual(response.data['message'], "Success")
|
||||
|
||||
def test_success_response_without_data(self):
|
||||
"""Test success_response without data"""
|
||||
response = success_response(message="Success")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertNotIn('data', response.data)
|
||||
self.assertEqual(response.data['message'], "Success")
|
||||
|
||||
def test_success_response_with_custom_status(self):
|
||||
"""Test success_response with custom status code"""
|
||||
data = {"id": 1}
|
||||
response = success_response(data=data, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data'], data)
|
||||
|
||||
def test_success_response_with_request_id(self):
|
||||
"""Test success_response includes request_id when request provided"""
|
||||
request = self.factory.get('/test/')
|
||||
request.request_id = 'test-request-id-123'
|
||||
|
||||
response = success_response(data={"id": 1}, request=request)
|
||||
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['request_id'], 'test-request-id-123')
|
||||
|
||||
def test_error_response_with_error_message(self):
|
||||
"""Test error_response with error message"""
|
||||
response = error_response(error="Validation failed")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], "Validation failed")
|
||||
|
||||
def test_error_response_with_errors_dict(self):
|
||||
"""Test error_response with field-specific errors"""
|
||||
errors = {"email": ["Invalid email format"], "password": ["Too short"]}
|
||||
response = error_response(error="Validation failed", errors=errors)
|
||||
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['error'], "Validation failed")
|
||||
self.assertEqual(response.data['errors'], errors)
|
||||
|
||||
def test_error_response_status_code_mapping(self):
|
||||
"""Test error_response maps status codes to default error messages"""
|
||||
# Test 401
|
||||
response = error_response(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertEqual(response.data['error'], 'Authentication required')
|
||||
|
||||
# Test 403
|
||||
response = error_response(status_code=status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data['error'], 'Permission denied')
|
||||
|
||||
# Test 404
|
||||
response = error_response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
self.assertEqual(response.data['error'], 'Resource not found')
|
||||
|
||||
# Test 409
|
||||
response = error_response(status_code=status.HTTP_409_CONFLICT)
|
||||
self.assertEqual(response.data['error'], 'Conflict')
|
||||
|
||||
# Test 422
|
||||
response = error_response(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
self.assertEqual(response.data['error'], 'Validation failed')
|
||||
|
||||
# Test 429
|
||||
response = error_response(status_code=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
self.assertEqual(response.data['error'], 'Rate limit exceeded')
|
||||
|
||||
# Test 500
|
||||
response = error_response(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertEqual(response.data['error'], 'Internal server error')
|
||||
|
||||
def test_error_response_with_request_id(self):
|
||||
"""Test error_response includes request_id when request provided"""
|
||||
request = self.factory.get('/test/')
|
||||
request.request_id = 'test-request-id-456'
|
||||
|
||||
response = error_response(error="Error occurred", request=request)
|
||||
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['request_id'], 'test-request-id-456')
|
||||
|
||||
def test_error_response_with_debug_info(self):
|
||||
"""Test error_response includes debug info when provided"""
|
||||
debug_info = {"exception_type": "ValueError", "message": "Test error"}
|
||||
response = error_response(error="Error", debug_info=debug_info)
|
||||
|
||||
self.assertFalse(response.data['success'])
|
||||
self.assertEqual(response.data['debug'], debug_info)
|
||||
|
||||
def test_paginated_response_with_data(self):
|
||||
"""Test paginated_response with paginated data"""
|
||||
paginated_data = {
|
||||
'count': 100,
|
||||
'next': 'http://test.com/api/v1/test/?page=2',
|
||||
'previous': None,
|
||||
'results': [{"id": 1}, {"id": 2}]
|
||||
}
|
||||
response = paginated_response(paginated_data, message="Success")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['count'], 100)
|
||||
self.assertEqual(response.data['next'], paginated_data['next'])
|
||||
self.assertEqual(response.data['previous'], None)
|
||||
self.assertEqual(response.data['results'], paginated_data['results'])
|
||||
self.assertEqual(response.data['message'], "Success")
|
||||
|
||||
def test_paginated_response_without_message(self):
|
||||
"""Test paginated_response without message"""
|
||||
paginated_data = {
|
||||
'count': 50,
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': []
|
||||
}
|
||||
response = paginated_response(paginated_data)
|
||||
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['count'], 50)
|
||||
self.assertNotIn('message', response.data)
|
||||
|
||||
def test_paginated_response_with_request_id(self):
|
||||
"""Test paginated_response includes request_id when request provided"""
|
||||
request = self.factory.get('/test/')
|
||||
request.request_id = 'test-request-id-789'
|
||||
|
||||
paginated_data = {
|
||||
'count': 10,
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': []
|
||||
}
|
||||
response = paginated_response(paginated_data, request=request)
|
||||
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['request_id'], 'test-request-id-789')
|
||||
|
||||
def test_paginated_response_fallback(self):
|
||||
"""Test paginated_response handles non-dict input"""
|
||||
response = paginated_response(None)
|
||||
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['count'], 0)
|
||||
self.assertIsNone(response.data['next'])
|
||||
self.assertIsNone(response.data['previous'])
|
||||
self.assertEqual(response.data['results'], [])
|
||||
|
||||
def test_get_request_id_from_request_object(self):
|
||||
"""Test get_request_id retrieves from request.request_id"""
|
||||
request = self.factory.get('/test/')
|
||||
request.request_id = 'request-id-from-object'
|
||||
|
||||
request_id = get_request_id(request)
|
||||
self.assertEqual(request_id, 'request-id-from-object')
|
||||
|
||||
def test_get_request_id_from_headers(self):
|
||||
"""Test get_request_id retrieves from headers"""
|
||||
request = self.factory.get('/test/', HTTP_X_REQUEST_ID='request-id-from-header')
|
||||
|
||||
request_id = get_request_id(request)
|
||||
self.assertEqual(request_id, 'request-id-from-header')
|
||||
|
||||
def test_get_request_id_generates_new(self):
|
||||
"""Test get_request_id generates new UUID if not found"""
|
||||
request = self.factory.get('/test/')
|
||||
|
||||
request_id = get_request_id(request)
|
||||
self.assertIsNotNone(request_id)
|
||||
self.assertIsInstance(request_id, str)
|
||||
# UUID format check
|
||||
import uuid
|
||||
try:
|
||||
uuid.UUID(request_id)
|
||||
except ValueError:
|
||||
self.fail("Generated request_id is not a valid UUID")
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""
|
||||
Unit tests for rate limiting
|
||||
Tests DebugScopedRateThrottle with bypass logic
|
||||
"""
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.auth.models import User, Account, Plan
|
||||
|
||||
|
||||
class ThrottlesTestCase(TestCase):
|
||||
"""Test cases for rate limiting"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.factory = APIRequestFactory()
|
||||
self.view = APIView()
|
||||
self.view.throttle_scope = 'planner'
|
||||
|
||||
# Create test plan and account
|
||||
self.plan = Plan.objects.create(
|
||||
name="Test Plan",
|
||||
slug="test-plan",
|
||||
price=0,
|
||||
credits_per_month=1000
|
||||
)
|
||||
|
||||
# Create owner user first
|
||||
self.owner_user = User.objects.create_user(
|
||||
username='owner',
|
||||
email='owner@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create test account with owner
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account",
|
||||
slug="test-account",
|
||||
plan=self.plan,
|
||||
owner=self.owner_user
|
||||
)
|
||||
|
||||
# Update owner user to have account
|
||||
self.owner_user.account = self.account
|
||||
self.owner_user.save()
|
||||
|
||||
# Create regular user
|
||||
self.user = User.objects.create_user(
|
||||
username='user',
|
||||
email='user@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
# Create admin user
|
||||
self.admin_user = User.objects.create_user(
|
||||
username='admin',
|
||||
email='admin@test.com',
|
||||
password='testpass123',
|
||||
role='admin',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
# Create system account owner
|
||||
self.system_owner = User.objects.create_user(
|
||||
username='system_owner',
|
||||
email='system_owner@test.com',
|
||||
password='testpass123',
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create system account user
|
||||
self.system_account = Account.objects.create(
|
||||
name="AWS Admin",
|
||||
slug="aws-admin",
|
||||
plan=self.plan,
|
||||
owner=self.system_owner
|
||||
)
|
||||
|
||||
self.system_owner.account = self.system_account
|
||||
self.system_owner.save()
|
||||
|
||||
self.system_user = User.objects.create_user(
|
||||
username='system',
|
||||
email='system@test.com',
|
||||
password='testpass123',
|
||||
role='viewer',
|
||||
account=self.system_account
|
||||
)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_debug_mode_bypass(self):
|
||||
"""Test throttling is bypassed in DEBUG mode"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.user
|
||||
|
||||
result = throttle.allow_request(request, self.view)
|
||||
self.assertTrue(result) # Should bypass in DEBUG mode
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=True)
|
||||
def test_env_bypass(self):
|
||||
"""Test throttling is bypassed when IGNY8_DEBUG_THROTTLE=True"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.user
|
||||
|
||||
result = throttle.allow_request(request, self.view)
|
||||
self.assertTrue(result) # Should bypass when IGNY8_DEBUG_THROTTLE=True
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_system_account_bypass(self):
|
||||
"""Test throttling is bypassed for system account users"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.system_user
|
||||
|
||||
result = throttle.allow_request(request, self.view)
|
||||
self.assertTrue(result) # System account users should bypass
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_admin_bypass(self):
|
||||
"""Test throttling is bypassed for admin/developer users"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.admin_user
|
||||
|
||||
result = throttle.allow_request(request, self.view)
|
||||
self.assertTrue(result) # Admin users should bypass
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_get_rate(self):
|
||||
"""Test get_rate returns correct rate for scope"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
throttle.scope = 'planner'
|
||||
|
||||
rate = throttle.get_rate()
|
||||
self.assertIsNotNone(rate)
|
||||
self.assertIn('/', rate) # Should be in format "60/min"
|
||||
|
||||
@override_settings(DEBUG=False, IGNY8_DEBUG_THROTTLE=False)
|
||||
def test_get_rate_default_fallback(self):
|
||||
"""Test get_rate falls back to default if scope not found"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
throttle.scope = 'nonexistent_scope'
|
||||
|
||||
rate = throttle.get_rate()
|
||||
self.assertIsNotNone(rate)
|
||||
self.assertEqual(rate, '100/min') # Should fallback to default
|
||||
|
||||
def test_parse_rate_minutes(self):
|
||||
"""Test parse_rate correctly parses minutes"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
|
||||
num, duration = throttle.parse_rate('60/min')
|
||||
self.assertEqual(num, 60)
|
||||
self.assertEqual(duration, 60)
|
||||
|
||||
def test_parse_rate_seconds(self):
|
||||
"""Test parse_rate correctly parses seconds"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
|
||||
num, duration = throttle.parse_rate('10/sec')
|
||||
self.assertEqual(num, 10)
|
||||
self.assertEqual(duration, 1)
|
||||
|
||||
def test_parse_rate_hours(self):
|
||||
"""Test parse_rate correctly parses hours"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
|
||||
num, duration = throttle.parse_rate('100/hour')
|
||||
self.assertEqual(num, 100)
|
||||
self.assertEqual(duration, 3600)
|
||||
|
||||
def test_parse_rate_invalid_format(self):
|
||||
"""Test parse_rate handles invalid format gracefully"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
|
||||
num, duration = throttle.parse_rate('invalid')
|
||||
self.assertEqual(num, 100) # Should default to 100
|
||||
self.assertEqual(duration, 60) # Should default to 60 seconds (1 min)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_debug_info_set(self):
|
||||
"""Test debug info is set when bypassing in DEBUG mode"""
|
||||
throttle = DebugScopedRateThrottle()
|
||||
request = self.factory.get('/test/')
|
||||
request.user = self.user
|
||||
|
||||
result = throttle.allow_request(request, self.view)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(hasattr(request, '_throttle_debug_info'))
|
||||
self.assertIn('scope', request._throttle_debug_info)
|
||||
self.assertIn('rate', request._throttle_debug_info)
|
||||
self.assertIn('limit', request._throttle_debug_info)
|
||||
|
||||
161
backend/igny8_core/auth/management/commands/cleanup_user_data.py
Normal file
161
backend/igny8_core/auth/management/commands/cleanup_user_data.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Management command to clean up all user-generated data (DESTRUCTIVE).
|
||||
This is used before V1.0 production launch to start with a clean database.
|
||||
|
||||
⚠️ WARNING: This permanently deletes ALL user data!
|
||||
|
||||
Usage:
|
||||
# DRY RUN (recommended first):
|
||||
python manage.py cleanup_user_data --dry-run
|
||||
|
||||
# ACTUAL CLEANUP (after reviewing dry-run):
|
||||
python manage.py cleanup_user_data --confirm
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up all user-generated data (DESTRUCTIVE - for pre-launch cleanup)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--confirm',
|
||||
action='store_true',
|
||||
help='Confirm you want to delete all user data'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force-staging',
|
||||
action='store_true',
|
||||
help='Treat current environment as staging (bypasses production check)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not options['confirm'] and not options['dry_run']:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('\n⚠️ ERROR: Must use --confirm or --dry-run flag\n')
|
||||
)
|
||||
self.stdout.write('Usage:')
|
||||
self.stdout.write(' python manage.py cleanup_user_data --dry-run # See what will be deleted')
|
||||
self.stdout.write(' python manage.py cleanup_user_data --confirm # Actually delete data\n')
|
||||
return
|
||||
|
||||
# Safety check: Prevent running in production unless explicitly allowed
|
||||
env = getattr(settings, 'ENVIRONMENT', 'production')
|
||||
if options.get('force_staging'):
|
||||
env = 'staging'
|
||||
self.stdout.write(self.style.WARNING('\n⚠️ FORCE-STAGING: Treating environment as staging\n'))
|
||||
|
||||
if env == 'production' and options['confirm']:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('\n⚠️ BLOCKED: Cannot run cleanup in PRODUCTION environment!\n')
|
||||
)
|
||||
self.stdout.write('To allow this, use --force-staging flag or set ENVIRONMENT to "staging" in settings.\n')
|
||||
return
|
||||
|
||||
# Import models
|
||||
from igny8_core.auth.models import Site, User
|
||||
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
|
||||
from igny8_core.business.content.models import Tasks, Content, Images
|
||||
from igny8_core.business.publishing.models import PublishingRecord
|
||||
from igny8_core.business.integration.models import SyncEvent
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.business.notifications.models import Notification
|
||||
from igny8_core.business.automation.models import AutomationRun
|
||||
|
||||
# Define models to clear (ORDER MATTERS - foreign keys)
|
||||
# Delete child records before parent records
|
||||
models_to_clear = [
|
||||
('Notifications', Notification),
|
||||
('Credit Usage Logs', CreditUsageLog),
|
||||
('Credit Transactions', CreditTransaction),
|
||||
('Sync Events', SyncEvent),
|
||||
('Publishing Records', PublishingRecord),
|
||||
('Automation Runs', AutomationRun),
|
||||
('Images', Images),
|
||||
('Content', Content),
|
||||
('Tasks', Tasks),
|
||||
('Content Ideas', ContentIdeas),
|
||||
('Clusters', Clusters),
|
||||
('Keywords', Keywords),
|
||||
('Sites', Site), # Sites should be near last (many foreign keys)
|
||||
# Note: We do NOT delete User - keep admin users
|
||||
]
|
||||
|
||||
if options['dry_run']:
|
||||
self.stdout.write(self.style.WARNING('\n' + '=' * 70))
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - No data will be deleted'))
|
||||
self.stdout.write(self.style.WARNING('=' * 70 + '\n'))
|
||||
|
||||
total_records = 0
|
||||
for name, model in models_to_clear:
|
||||
count = model.objects.count()
|
||||
total_records += count
|
||||
status = '✓' if count > 0 else '·'
|
||||
self.stdout.write(f' {status} Would delete {count:6d} {name}')
|
||||
|
||||
# Count users (not deleted)
|
||||
user_count = User.objects.count()
|
||||
self.stdout.write(f'\n → Keeping {user_count:6d} Users (not deleted)')
|
||||
|
||||
self.stdout.write(f'\n Total records to delete: {total_records:,}')
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS('\nTo proceed with actual deletion, run:'))
|
||||
self.stdout.write(' python manage.py cleanup_user_data --confirm\n')
|
||||
return
|
||||
|
||||
# ACTUAL DELETION
|
||||
self.stdout.write(self.style.ERROR('\n' + '=' * 70))
|
||||
self.stdout.write(self.style.ERROR('⚠️ DELETING ALL USER DATA - THIS CANNOT BE UNDONE!'))
|
||||
self.stdout.write(self.style.ERROR('=' * 70 + '\n'))
|
||||
|
||||
# Final confirmation prompt
|
||||
confirm_text = input('Type "DELETE ALL DATA" to proceed: ')
|
||||
if confirm_text != 'DELETE ALL DATA':
|
||||
self.stdout.write(self.style.WARNING('\nAborted. Data was NOT deleted.\n'))
|
||||
return
|
||||
|
||||
self.stdout.write('\nProceeding with deletion...\n')
|
||||
|
||||
deleted_counts = {}
|
||||
failed_deletions = []
|
||||
|
||||
with transaction.atomic():
|
||||
for name, model in models_to_clear:
|
||||
try:
|
||||
count = model.objects.count()
|
||||
if count > 0:
|
||||
model.objects.all().delete()
|
||||
deleted_counts[name] = count
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Deleted {count:6d} {name}')
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'· Skipped {count:6d} {name} (already empty)')
|
||||
)
|
||||
except Exception as e:
|
||||
failed_deletions.append((name, str(e)))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Failed to delete {name}: {str(e)}')
|
||||
)
|
||||
|
||||
# Summary
|
||||
total_deleted = sum(deleted_counts.values())
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS(f'\nUser Data Cleanup Complete!\n'))
|
||||
self.stdout.write(f' Total records deleted: {total_deleted:,}')
|
||||
self.stdout.write(f' Failed deletions: {len(failed_deletions)}')
|
||||
|
||||
if failed_deletions:
|
||||
self.stdout.write(self.style.WARNING('\nFailed deletions:'))
|
||||
for name, error in failed_deletions:
|
||||
self.stdout.write(f' - {name}: {error}')
|
||||
|
||||
self.stdout.write('\n' + '=' * 70 + '\n')
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Management command to export system configuration data to JSON files.
|
||||
This exports Plans, Credit Costs, AI Models, Industries, Sectors, Seed Keywords, etc.
|
||||
|
||||
Usage:
|
||||
python manage.py export_system_config --output-dir=backups/config
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core import serializers
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Export system configuration data to JSON files for V1.0 backup'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--output-dir',
|
||||
default='backups/config',
|
||||
help='Output directory for config files (relative to project root)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
output_dir = options['output_dir']
|
||||
|
||||
# Make output_dir absolute if it's relative
|
||||
if not os.path.isabs(output_dir):
|
||||
# Get project root (parent of manage.py)
|
||||
import sys
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
output_dir = os.path.join(project_root, '..', output_dir)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\nExporting system configuration to: {output_dir}\n'))
|
||||
|
||||
# Import models with correct paths
|
||||
from igny8_core.auth.models import Plan, Industry, Sector, SeedKeyword
|
||||
from igny8_core.modules.system.models import AuthorProfile, AIPrompt
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings, GlobalAIPrompt
|
||||
from igny8_core.business.billing.models import CreditCostConfig, AIModelConfig
|
||||
|
||||
# Define what to export
|
||||
exports = {
|
||||
'plans': (Plan.objects.all(), 'Subscription Plans'),
|
||||
'credit_costs': (CreditCostConfig.objects.all(), 'Credit Cost Configurations'),
|
||||
'ai_models': (AIModelConfig.objects.all(), 'AI Model Configurations'),
|
||||
'global_integrations': (GlobalIntegrationSettings.objects.all(), 'Global Integration Settings'),
|
||||
'industries': (Industry.objects.all(), 'Industries'),
|
||||
'sectors': (Sector.objects.all(), 'Sectors'),
|
||||
'seed_keywords': (SeedKeyword.objects.all(), 'Seed Keywords'),
|
||||
'author_profiles': (AuthorProfile.objects.all(), 'Author Profiles'),
|
||||
'prompts': (AIPrompt.objects.all(), 'AI Prompts'),
|
||||
'global_prompts': (GlobalAIPrompt.objects.all(), 'Global AI Prompts'),
|
||||
}
|
||||
|
||||
successful_exports = []
|
||||
failed_exports = []
|
||||
|
||||
for name, (queryset, description) in exports.items():
|
||||
try:
|
||||
count = queryset.count()
|
||||
data = serializers.serialize('json', queryset, indent=2)
|
||||
filepath = os.path.join(output_dir, f'{name}.json')
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Exported {count:4d} {description:30s} → {name}.json')
|
||||
)
|
||||
successful_exports.append(name)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Failed to export {description}: {str(e)}')
|
||||
)
|
||||
failed_exports.append((name, str(e)))
|
||||
|
||||
# Export metadata
|
||||
metadata = {
|
||||
'exported_at': datetime.now().isoformat(),
|
||||
'django_version': self.get_django_version(),
|
||||
'database': self.get_database_info(),
|
||||
'successful_exports': successful_exports,
|
||||
'failed_exports': failed_exports,
|
||||
'export_count': len(successful_exports),
|
||||
}
|
||||
|
||||
metadata_path = os.path.join(output_dir, 'export_metadata.json')
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n✓ Metadata saved to export_metadata.json'))
|
||||
|
||||
# Summary
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS(f'\nSystem Configuration Export Complete!\n'))
|
||||
self.stdout.write(f' Successful: {len(successful_exports)} exports')
|
||||
self.stdout.write(f' Failed: {len(failed_exports)} exports')
|
||||
self.stdout.write(f' Location: {output_dir}\n')
|
||||
|
||||
if failed_exports:
|
||||
self.stdout.write(self.style.WARNING('\nFailed exports:'))
|
||||
for name, error in failed_exports:
|
||||
self.stdout.write(f' - {name}: {error}')
|
||||
|
||||
self.stdout.write('=' * 70 + '\n')
|
||||
|
||||
def get_django_version(self):
|
||||
import django
|
||||
return django.get_version()
|
||||
|
||||
def get_database_info(self):
|
||||
from django.conf import settings
|
||||
db_config = settings.DATABASES.get('default', {})
|
||||
return {
|
||||
'engine': db_config.get('ENGINE', '').split('.')[-1],
|
||||
'name': db_config.get('NAME', ''),
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated manually on 2026-01-13
|
||||
# Purpose: Drop legacy igny8_site_blueprints table and related structures
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0021_accounttrash_sectortrash_sitetrash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
-- Drop indexes first
|
||||
DROP INDEX IF EXISTS igny8_site__account__38f18a_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__hosting_7a9a3e_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__hosting_c4bb41_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__site_id__5f0a4e_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__site_id_cb1aca_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__status_247ddc_idx;
|
||||
DROP INDEX IF EXISTS igny8_site__status_e7ca10_idx;
|
||||
|
||||
-- Drop the table
|
||||
DROP TABLE IF EXISTS igny8_site_blueprints CASCADE;
|
||||
|
||||
-- Drop the sequence
|
||||
DROP SEQUENCE IF EXISTS igny8_site_blueprints_id_seq;
|
||||
""",
|
||||
reverse_sql="""
|
||||
-- Cannot reverse this migration - table structure was removed from models
|
||||
-- If rollback is needed, restore from database backup
|
||||
SELECT 1;
|
||||
"""
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Linker module tests
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
Tests for Linker API endpoints
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class LinkerAPITests(IntegrationTestBase):
|
||||
"""Tests for Linker API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test content.</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_process_endpoint_requires_authentication(self):
|
||||
"""Test that process endpoint requires authentication"""
|
||||
client = APIClient() # Not authenticated
|
||||
response = client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.process')
|
||||
def test_process_endpoint_success(self, mock_process):
|
||||
"""Test successful processing"""
|
||||
mock_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Linked Content",
|
||||
html_content="<p>Linked.</p>",
|
||||
internal_links=[{'content_id': 1, 'anchor_text': 'test'}],
|
||||
linker_version=1,
|
||||
word_count=100
|
||||
)
|
||||
mock_process.return_value = mock_content
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data']['content_id'], self.content.id)
|
||||
self.assertEqual(response.data['data']['links_added'], 1)
|
||||
|
||||
def test_process_endpoint_invalid_content_id(self):
|
||||
"""Test process endpoint with invalid content ID"""
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': 99999
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.process')
|
||||
def test_process_endpoint_insufficient_credits(self, mock_process):
|
||||
"""Test process endpoint with insufficient credits"""
|
||||
mock_process.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_402_PAYMENT_REQUIRED)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.batch_process')
|
||||
def test_batch_process_endpoint_success(self, mock_batch):
|
||||
"""Test successful batch processing"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
mock_batch.return_value = [self.content, content2]
|
||||
|
||||
response = self.client.post('/api/v1/linker/batch_process/', {
|
||||
'content_ids': [self.content.id, content2.id]
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(len(response.data['data']), 2)
|
||||
|
||||
def test_batch_process_endpoint_validation(self):
|
||||
"""Test batch process endpoint validation"""
|
||||
response = self.client.post('/api/v1/linker/batch_process/', {
|
||||
'content_ids': [] # Empty list
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_process_endpoint_respects_account_isolation(self):
|
||||
"""Test that process endpoint respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Content",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': other_content.id
|
||||
}, format='json')
|
||||
|
||||
# Should return 400 because content belongs to different account
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Optimizer module tests
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
Tests for Optimizer API endpoints
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizerAPITests(IntegrationTestBase):
|
||||
"""Tests for Optimizer API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
def test_optimize_endpoint_requires_authentication(self):
|
||||
"""Test that optimize endpoint requires authentication"""
|
||||
client = APIClient() # Not authenticated
|
||||
response = client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_optimize_endpoint_success(self, mock_optimize):
|
||||
"""Test successful optimization"""
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized",
|
||||
html_content="<p>Optimized.</p>",
|
||||
word_count=500,
|
||||
optimizer_version=1,
|
||||
optimization_scores={'overall_score': 75.0}
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
# Create optimization task
|
||||
task = OptimizationTask.objects.create(
|
||||
content=optimized_content,
|
||||
scores_before={'overall_score': 50.0},
|
||||
scores_after={'overall_score': 75.0},
|
||||
status='completed',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id,
|
||||
'entry_point': 'writer'
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data']['content_id'], self.content.id)
|
||||
|
||||
def test_optimize_endpoint_all_entry_points(self):
|
||||
"""Test optimize endpoint with all entry point values"""
|
||||
entry_points = ['auto', 'writer', 'wordpress', 'external', 'manual']
|
||||
|
||||
for entry_point in entry_points:
|
||||
with patch(f'igny8_core.modules.optimizer.views.OptimizerService.optimize_{entry_point if entry_point != "auto" else "from_writer"}') as mock_opt:
|
||||
if entry_point == 'auto':
|
||||
mock_opt = patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
mock_opt.return_value = self.content
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id,
|
||||
'entry_point': entry_point
|
||||
}, format='json')
|
||||
|
||||
# Should accept all entry points
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_batch_optimize_endpoint_success(self, mock_optimize):
|
||||
"""Test successful batch optimization"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=500,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
mock_optimize.return_value = self.content
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/batch_optimize/', {
|
||||
'content_ids': [self.content.id, content2.id],
|
||||
'entry_point': 'writer'
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertIn('succeeded', response.data['data'])
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.analyze_only')
|
||||
def test_analyze_endpoint_success(self, mock_analyze):
|
||||
"""Test analyze endpoint returns scores"""
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/analyze/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertIn('scores', response.data['data'])
|
||||
self.assertEqual(response.data['data']['scores']['overall_score'], 55.0)
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_optimize_endpoint_insufficient_credits(self, mock_optimize):
|
||||
"""Test optimize endpoint with insufficient credits"""
|
||||
mock_optimize.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_402_PAYMENT_REQUIRED)
|
||||
|
||||
def test_optimize_endpoint_invalid_content_id(self):
|
||||
"""Test optimize endpoint with invalid content ID"""
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': 99999
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_optimize_endpoint_respects_account_isolation(self):
|
||||
"""Test that optimize endpoint respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Content",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': other_content.id
|
||||
}, format='json')
|
||||
|
||||
# Should return 400 because content belongs to different account
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""
|
||||
Test settings - auto-clobber test database
|
||||
"""
|
||||
from igny8_core.settings import *
|
||||
|
||||
# Auto-clobber test database
|
||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
||||
|
||||
23348
backups/20260113_pre_v1_full_backup.sql
Normal file
23348
backups/20260113_pre_v1_full_backup.sql
Normal file
File diff suppressed because one or more lines are too long
191
backups/config/20260113/ai_models.json
Normal file
191
backups/config/20260113/ai_models.json
Normal file
@@ -0,0 +1,191 @@
|
||||
[
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"model_name": "bytedance:seedream@4.5",
|
||||
"model_type": "image",
|
||||
"provider": "runware",
|
||||
"display_name": "Seedream 4.5 - High Quality",
|
||||
"is_default": false,
|
||||
"is_active": true,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": null,
|
||||
"credits_per_image": 5,
|
||||
"quality_tier": "quality_option2",
|
||||
"landscape_size": "2560x1440",
|
||||
"square_size": "2048x2048",
|
||||
"valid_sizes": [
|
||||
"2048x2048",
|
||||
"2304x1728",
|
||||
"2560x1440",
|
||||
"1728x2304",
|
||||
"1440x2560"
|
||||
],
|
||||
"max_tokens": null,
|
||||
"context_window": null,
|
||||
"capabilities": {
|
||||
"high_resolution": true,
|
||||
"provider_settings": {
|
||||
"bytedance": {
|
||||
"maxSequentialImages": 4
|
||||
}
|
||||
},
|
||||
"max_sequential_images": 4
|
||||
},
|
||||
"created_at": "2026-01-12T09:31:14.196Z",
|
||||
"updated_at": "2026-01-12T18:26:59.096Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"model_name": "dall-e-3",
|
||||
"model_type": "image",
|
||||
"provider": "openai",
|
||||
"display_name": "DALL-E 3 - HD Quality",
|
||||
"is_default": false,
|
||||
"is_active": false,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": null,
|
||||
"credits_per_image": 5,
|
||||
"quality_tier": "quality",
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": null,
|
||||
"capabilities": {},
|
||||
"created_at": "2025-12-24T01:21:08.347Z",
|
||||
"updated_at": "2026-01-03T21:46:19.717Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"model_name": "google:4@2",
|
||||
"model_type": "image",
|
||||
"provider": "runware",
|
||||
"display_name": "Nano Banana - Premium",
|
||||
"is_default": false,
|
||||
"is_active": false,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": null,
|
||||
"credits_per_image": 15,
|
||||
"quality_tier": "premium",
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": null,
|
||||
"capabilities": {},
|
||||
"created_at": "2025-12-24T01:21:08.347Z",
|
||||
"updated_at": "2026-01-03T20:12:21.724Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"model_name": "runware:97@1",
|
||||
"model_type": "image",
|
||||
"provider": "runware",
|
||||
"display_name": "Hi Dream Full - Basic",
|
||||
"is_default": true,
|
||||
"is_active": true,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": null,
|
||||
"credits_per_image": 1,
|
||||
"quality_tier": "basic",
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": null,
|
||||
"capabilities": {},
|
||||
"created_at": "2026-01-03T16:54:17.244Z",
|
||||
"updated_at": "2026-01-12T23:39:32.397Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"model_name": "gpt-4o",
|
||||
"model_type": "text",
|
||||
"provider": "openai",
|
||||
"display_name": "GPT-4o - High Quality with Vision",
|
||||
"is_default": false,
|
||||
"is_active": false,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": 1000,
|
||||
"credits_per_image": null,
|
||||
"quality_tier": null,
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": 128000,
|
||||
"capabilities": {},
|
||||
"created_at": "2025-12-24T01:21:08.346Z",
|
||||
"updated_at": "2025-12-24T01:21:08.346Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"model_name": "gpt-4o-mini",
|
||||
"model_type": "text",
|
||||
"provider": "openai",
|
||||
"display_name": "GPT-4o mini - Fast & Affordable",
|
||||
"is_default": true,
|
||||
"is_active": true,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": 10000,
|
||||
"credits_per_image": null,
|
||||
"quality_tier": null,
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": 128000,
|
||||
"capabilities": {},
|
||||
"created_at": "2025-12-24T01:21:08.345Z",
|
||||
"updated_at": "2026-01-04T22:12:40.728Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.aimodelconfig",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"model_name": "gpt-5.1",
|
||||
"model_type": "text",
|
||||
"provider": "openai",
|
||||
"display_name": "GPT-5.1 - Premium",
|
||||
"is_default": false,
|
||||
"is_active": true,
|
||||
"cost_per_1k_input": null,
|
||||
"cost_per_1k_output": null,
|
||||
"tokens_per_credit": 1000,
|
||||
"credits_per_image": null,
|
||||
"quality_tier": null,
|
||||
"landscape_size": null,
|
||||
"square_size": "1024x1024",
|
||||
"valid_sizes": [],
|
||||
"max_tokens": null,
|
||||
"context_window": 8192,
|
||||
"capabilities": {},
|
||||
"created_at": "2025-12-24T01:21:08.347Z",
|
||||
"updated_at": "2026-01-03T23:49:12.914Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
2
backups/config/20260113/author_profiles.json
Normal file
2
backups/config/20260113/author_profiles.json
Normal file
@@ -0,0 +1,2 @@
|
||||
[
|
||||
]
|
||||
102
backups/config/20260113/credit_costs.json
Normal file
102
backups/config/20260113/credit_costs.json
Normal file
@@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "clustering",
|
||||
"fields": {
|
||||
"display_name": "Keyword Clustering",
|
||||
"base_credits": 1,
|
||||
"is_active": true,
|
||||
"description": "AI-powered keyword clustering into semantic groups"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "content_generation",
|
||||
"fields": {
|
||||
"display_name": "Content Generation",
|
||||
"base_credits": 1,
|
||||
"is_active": true,
|
||||
"description": "AI-powered article content generation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "idea_generation",
|
||||
"fields": {
|
||||
"display_name": "Content Ideas Generation",
|
||||
"base_credits": 1,
|
||||
"is_active": true,
|
||||
"description": "Generate content ideas from keyword clusters"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "image_generation",
|
||||
"fields": {
|
||||
"display_name": "Image Generation",
|
||||
"base_credits": 1,
|
||||
"is_active": true,
|
||||
"description": "AI-powered image generation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "image_prompt_extraction",
|
||||
"fields": {
|
||||
"display_name": "Image Prompt Extraction",
|
||||
"base_credits": 1,
|
||||
"is_active": true,
|
||||
"description": "Extract image prompts from content"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "linking",
|
||||
"fields": {
|
||||
"display_name": "Internal Linking",
|
||||
"base_credits": 1,
|
||||
"is_active": false,
|
||||
"description": "AI-powered internal link suggestions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "optimization",
|
||||
"fields": {
|
||||
"display_name": "Content Optimization",
|
||||
"base_credits": 1,
|
||||
"is_active": false,
|
||||
"description": "AI-powered content optimization"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "reparse",
|
||||
"fields": {
|
||||
"display_name": "Content Reparse",
|
||||
"base_credits": 1,
|
||||
"is_active": false,
|
||||
"description": "Reparse existing content"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "site_page_generation",
|
||||
"fields": {
|
||||
"display_name": "Site Page Generation",
|
||||
"base_credits": 1,
|
||||
"is_active": false,
|
||||
"description": "Generate site pages from blueprint"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "billing.creditcostconfig",
|
||||
"pk": "site_structure_generation",
|
||||
"fields": {
|
||||
"display_name": "Site Structure Generation",
|
||||
"base_credits": 1,
|
||||
"is_active": false,
|
||||
"description": "Generate complete site blueprint"
|
||||
}
|
||||
}
|
||||
]
|
||||
22
backups/config/20260113/export_metadata.json
Normal file
22
backups/config/20260113/export_metadata.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"exported_at": "2026-01-13T03:08:16.424735",
|
||||
"django_version": "5.2.10",
|
||||
"database": {
|
||||
"engine": "postgresql",
|
||||
"name": "igny8_db"
|
||||
},
|
||||
"successful_exports": [
|
||||
"plans",
|
||||
"credit_costs",
|
||||
"ai_models",
|
||||
"global_integrations",
|
||||
"industries",
|
||||
"sectors",
|
||||
"seed_keywords",
|
||||
"author_profiles",
|
||||
"prompts",
|
||||
"global_prompts"
|
||||
],
|
||||
"failed_exports": [],
|
||||
"export_count": 10
|
||||
}
|
||||
32
backups/config/20260113/global_integrations.json
Normal file
32
backups/config/20260113/global_integrations.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"model": "system.globalintegrationsettings",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"openai_api_key": "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",
|
||||
"openai_model": "gpt-4o-mini",
|
||||
"openai_temperature": 0.7,
|
||||
"openai_max_tokens": 16000,
|
||||
"anthropic_api_key": "",
|
||||
"anthropic_model": "claude-3-5-sonnet-20241022",
|
||||
"anthropic_temperature": 0.7,
|
||||
"anthropic_max_tokens": 8192,
|
||||
"default_text_provider": "openai",
|
||||
"dalle_api_key": "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",
|
||||
"dalle_model": "dall-e-3",
|
||||
"dalle_size": "1792x1024",
|
||||
"runware_api_key": "tuHmZhhyUcArJUQ3r0Jiw8ViPaiit0Z3",
|
||||
"runware_model": "bria:10@1",
|
||||
"bria_api_key": "",
|
||||
"bria_model": "bria-2.3",
|
||||
"default_image_service": "openai",
|
||||
"image_quality": "hd",
|
||||
"image_style": "realistic",
|
||||
"max_in_article_images": 4,
|
||||
"desktop_image_size": "1024x1024",
|
||||
"is_active": true,
|
||||
"last_updated": "2026-01-03T21:54:01.912Z",
|
||||
"updated_by": null
|
||||
}
|
||||
}
|
||||
]
|
||||
177
backups/config/20260113/global_prompts.json
Normal file
177
backups/config/20260113/global_prompts.json
Normal file
File diff suppressed because one or more lines are too long
158
backups/config/20260113/industries.json
Normal file
158
backups/config/20260113/industries.json
Normal file
@@ -0,0 +1,158 @@
|
||||
[
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 22,
|
||||
"fields": {
|
||||
"name": "Apparel & Fashion",
|
||||
"slug": "apparel-fashion",
|
||||
"description": "Fashion, clothing, and apparel industry",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.128Z",
|
||||
"updated_at": "2025-11-19T20:57:22.128Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"name": "Automotive",
|
||||
"slug": "automotive",
|
||||
"description": "Automotive sales, services, and parts",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-04T16:43:57.026Z",
|
||||
"updated_at": "2025-11-19T20:57:22.146Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 23,
|
||||
"fields": {
|
||||
"name": "Beauty & Personal Care",
|
||||
"slug": "beauty-personal-care",
|
||||
"description": "Beauty, skincare, and personal care products and services",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.140Z",
|
||||
"updated_at": "2025-11-19T20:57:22.141Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 29,
|
||||
"fields": {
|
||||
"name": "Education & Training",
|
||||
"slug": "education-training",
|
||||
"description": "Educational institutions, training programs, and learning services",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.190Z",
|
||||
"updated_at": "2025-11-19T20:57:22.190Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"name": "Fashion & Apparel",
|
||||
"slug": "fashion-apparel",
|
||||
"description": "Fashion, clothing, and apparel businesses",
|
||||
"is_active": false,
|
||||
"created_at": "2025-11-04T16:43:57.035Z",
|
||||
"updated_at": "2025-11-19T21:05:19.935Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 28,
|
||||
"fields": {
|
||||
"name": "Finance & Insurance",
|
||||
"slug": "finance-insurance",
|
||||
"description": "Financial services, banking, insurance, and investment",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.182Z",
|
||||
"updated_at": "2025-11-19T20:57:22.182Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Food & Beverage",
|
||||
"slug": "food-beverage",
|
||||
"description": "Restaurants, food services, and beverage industry",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-04T16:43:57.006Z",
|
||||
"updated_at": "2025-11-19T20:57:22.196Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 25,
|
||||
"fields": {
|
||||
"name": "Healthcare & Medical",
|
||||
"slug": "healthcare-medical",
|
||||
"description": "Healthcare services, medical practices, and health-related services",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.161Z",
|
||||
"updated_at": "2025-11-19T20:57:22.161Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 24,
|
||||
"fields": {
|
||||
"name": "Home & Furniture",
|
||||
"slug": "home-furniture",
|
||||
"description": "Furniture, home decor, and home improvement",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.155Z",
|
||||
"updated_at": "2025-11-19T20:57:22.155Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 21,
|
||||
"fields": {
|
||||
"name": "Home & Garden",
|
||||
"slug": "home-garden",
|
||||
"description": "Home improvement, gardening, landscaping, and interior design",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-05T11:07:20.331Z",
|
||||
"updated_at": "2025-11-05T11:07:20.331Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 26,
|
||||
"fields": {
|
||||
"name": "Real Estate & Construction",
|
||||
"slug": "real-estate-construction",
|
||||
"description": "Real estate, property management, and construction services",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.168Z",
|
||||
"updated_at": "2025-11-19T20:57:22.168Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Technology",
|
||||
"slug": "technology",
|
||||
"description": "Software, cloud computing, cybersecurity, and technology services",
|
||||
"is_active": false,
|
||||
"created_at": "2025-11-04T14:56:39.099Z",
|
||||
"updated_at": "2025-11-19T21:05:19.931Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.industry",
|
||||
"pk": 27,
|
||||
"fields": {
|
||||
"name": "Technology & IT Services",
|
||||
"slug": "technology-it-services",
|
||||
"description": "Technology services, software development, and IT solutions",
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-19T20:57:22.175Z",
|
||||
"updated_at": "2025-11-19T20:57:22.175Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
191
backups/config/20260113/plans.json
Normal file
191
backups/config/20260113/plans.json
Normal file
@@ -0,0 +1,191 @@
|
||||
[
|
||||
{
|
||||
"model": "igny8_core_auth.plan",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Internal (System/Superuser)",
|
||||
"slug": "internal",
|
||||
"price": "0.00",
|
||||
"original_price": null,
|
||||
"billing_cycle": "monthly",
|
||||
"annual_discount_percent": 15,
|
||||
"is_featured": false,
|
||||
"features": [
|
||||
"ai_writer",
|
||||
"image_gen",
|
||||
"auto_publish",
|
||||
"custom_prompts",
|
||||
"unlimited"
|
||||
],
|
||||
"is_active": true,
|
||||
"is_internal": true,
|
||||
"created_at": "2025-11-08T23:14:16.130Z",
|
||||
"max_users": 10000,
|
||||
"max_sites": 20,
|
||||
"max_industries": null,
|
||||
"max_author_profiles": 5,
|
||||
"max_keywords": 100000,
|
||||
"max_ahrefs_queries": 500,
|
||||
"included_credits": 10000,
|
||||
"extra_credit_price": "0.01",
|
||||
"allow_credit_topup": true,
|
||||
"auto_credit_topup_threshold": null,
|
||||
"auto_credit_topup_amount": null,
|
||||
"stripe_product_id": null,
|
||||
"stripe_price_id": null,
|
||||
"credits_per_month": 2000
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.plan",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Free Plan",
|
||||
"slug": "free",
|
||||
"price": "0.00",
|
||||
"original_price": null,
|
||||
"billing_cycle": "monthly",
|
||||
"annual_discount_percent": 15,
|
||||
"is_featured": false,
|
||||
"features": [
|
||||
"1 Site",
|
||||
"50 Keywords",
|
||||
"100 Credits",
|
||||
"10K Words",
|
||||
"10 Clusters",
|
||||
"20 Images"
|
||||
],
|
||||
"is_active": false,
|
||||
"is_internal": false,
|
||||
"created_at": "2025-11-02T22:01:17.053Z",
|
||||
"max_users": 1,
|
||||
"max_sites": 1,
|
||||
"max_industries": 1,
|
||||
"max_author_profiles": 2,
|
||||
"max_keywords": 100,
|
||||
"max_ahrefs_queries": 0,
|
||||
"included_credits": 100,
|
||||
"extra_credit_price": "0.00",
|
||||
"allow_credit_topup": false,
|
||||
"auto_credit_topup_threshold": null,
|
||||
"auto_credit_topup_amount": null,
|
||||
"stripe_product_id": null,
|
||||
"stripe_price_id": null,
|
||||
"credits_per_month": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.plan",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Starter",
|
||||
"slug": "starter",
|
||||
"price": "99.00",
|
||||
"original_price": null,
|
||||
"billing_cycle": "monthly",
|
||||
"annual_discount_percent": 10,
|
||||
"is_featured": false,
|
||||
"features": [
|
||||
"50 Pages/Articles",
|
||||
"2 Sites",
|
||||
"2 Team Users",
|
||||
"500 Keywords",
|
||||
"200 Images",
|
||||
"AI SEO"
|
||||
],
|
||||
"is_active": true,
|
||||
"is_internal": false,
|
||||
"created_at": "2025-11-04T14:55:33.381Z",
|
||||
"max_users": 2,
|
||||
"max_sites": 2,
|
||||
"max_industries": null,
|
||||
"max_author_profiles": 5,
|
||||
"max_keywords": 500,
|
||||
"max_ahrefs_queries": 50,
|
||||
"included_credits": 1000,
|
||||
"extra_credit_price": "0.10",
|
||||
"allow_credit_topup": true,
|
||||
"auto_credit_topup_threshold": null,
|
||||
"auto_credit_topup_amount": null,
|
||||
"stripe_product_id": "prod_TkGrgKWbrBo4sX",
|
||||
"stripe_price_id": "price_1SmmJhPdRe4dWeLwSufhikqW",
|
||||
"credits_per_month": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.plan",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Growth",
|
||||
"slug": "growth",
|
||||
"price": "199.00",
|
||||
"original_price": null,
|
||||
"billing_cycle": "monthly",
|
||||
"annual_discount_percent": 10,
|
||||
"is_featured": true,
|
||||
"features": [
|
||||
"200 Pages/Articles",
|
||||
"5 Sites",
|
||||
"3 Team Users",
|
||||
"2000 Keywords",
|
||||
"800 Images",
|
||||
"AI SEO"
|
||||
],
|
||||
"is_active": true,
|
||||
"is_internal": false,
|
||||
"created_at": "2025-11-07T11:46:29.144Z",
|
||||
"max_users": 3,
|
||||
"max_sites": 5,
|
||||
"max_industries": null,
|
||||
"max_author_profiles": 5,
|
||||
"max_keywords": 2000,
|
||||
"max_ahrefs_queries": 200,
|
||||
"included_credits": 2000,
|
||||
"extra_credit_price": "0.08",
|
||||
"allow_credit_topup": true,
|
||||
"auto_credit_topup_threshold": null,
|
||||
"auto_credit_topup_amount": null,
|
||||
"stripe_product_id": "prod_TkGsliLPq7Sl8w",
|
||||
"stripe_price_id": "price_1SmmKMPdRe4dWeLw9nfQUVus",
|
||||
"credits_per_month": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.plan",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Scale",
|
||||
"slug": "scale",
|
||||
"price": "299.00",
|
||||
"original_price": null,
|
||||
"billing_cycle": "monthly",
|
||||
"annual_discount_percent": 10,
|
||||
"is_featured": false,
|
||||
"features": [
|
||||
"500 Pages/Articles",
|
||||
"99 Sites",
|
||||
"5 Team Users",
|
||||
"5000 Keywords",
|
||||
"2000 Images",
|
||||
"AI SEO"
|
||||
],
|
||||
"is_active": true,
|
||||
"is_internal": false,
|
||||
"created_at": "2025-11-07T11:46:29.148Z",
|
||||
"max_users": 5,
|
||||
"max_sites": 99,
|
||||
"max_industries": null,
|
||||
"max_author_profiles": 10,
|
||||
"max_keywords": 5000,
|
||||
"max_ahrefs_queries": 500,
|
||||
"included_credits": 5000,
|
||||
"extra_credit_price": "0.06",
|
||||
"allow_credit_topup": true,
|
||||
"auto_credit_topup_threshold": null,
|
||||
"auto_credit_topup_amount": null,
|
||||
"stripe_product_id": "prod_TkGs64iCjpb9Ok",
|
||||
"stripe_price_id": "price_1SmmL6PdRe4dWeLwisBUvC0N",
|
||||
"credits_per_month": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
16
backups/config/20260113/prompts.json
Normal file
16
backups/config/20260113/prompts.json
Normal file
File diff suppressed because one or more lines are too long
86
backups/config/20260113/sectors.json
Normal file
86
backups/config/20260113/sectors.json
Normal file
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"model": "igny8_core_auth.sector",
|
||||
"pk": 50,
|
||||
"fields": {
|
||||
"is_deleted": false,
|
||||
"deleted_at": null,
|
||||
"restore_until": null,
|
||||
"delete_reason": null,
|
||||
"deleted_by": null,
|
||||
"account": 90,
|
||||
"site": 21,
|
||||
"industry_sector": 68,
|
||||
"name": "Footwear",
|
||||
"slug": "footwear",
|
||||
"description": "",
|
||||
"is_active": false,
|
||||
"status": "active",
|
||||
"created_at": "2025-12-10T07:51:10.139Z",
|
||||
"updated_at": "2025-12-10T07:51:10.139Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.sector",
|
||||
"pk": 51,
|
||||
"fields": {
|
||||
"is_deleted": false,
|
||||
"deleted_at": null,
|
||||
"restore_until": null,
|
||||
"delete_reason": null,
|
||||
"deleted_by": null,
|
||||
"account": 90,
|
||||
"site": 21,
|
||||
"industry_sector": 66,
|
||||
"name": "Kidswear",
|
||||
"slug": "kidswear",
|
||||
"description": "",
|
||||
"is_active": false,
|
||||
"status": "active",
|
||||
"created_at": "2025-12-10T07:51:10.144Z",
|
||||
"updated_at": "2025-12-10T07:51:10.144Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.sector",
|
||||
"pk": 61,
|
||||
"fields": {
|
||||
"is_deleted": false,
|
||||
"deleted_at": null,
|
||||
"restore_until": null,
|
||||
"delete_reason": null,
|
||||
"deleted_by": null,
|
||||
"account": 90,
|
||||
"site": 21,
|
||||
"industry_sector": 101,
|
||||
"name": "Physiotherapy & Rehabilitation",
|
||||
"slug": "physiotherapy-rehabilitation",
|
||||
"description": "",
|
||||
"is_active": true,
|
||||
"status": "active",
|
||||
"created_at": "2025-12-17T05:40:13.377Z",
|
||||
"updated_at": "2026-01-01T04:22:28.190Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "igny8_core_auth.sector",
|
||||
"pk": 62,
|
||||
"fields": {
|
||||
"is_deleted": false,
|
||||
"deleted_at": null,
|
||||
"restore_until": null,
|
||||
"delete_reason": null,
|
||||
"deleted_by": null,
|
||||
"account": 90,
|
||||
"site": 21,
|
||||
"industry_sector": 103,
|
||||
"name": "Mental Health & Therapy",
|
||||
"slug": "mental-health-therapy",
|
||||
"description": "",
|
||||
"is_active": true,
|
||||
"status": "active",
|
||||
"created_at": "2026-01-01T04:22:28.197Z",
|
||||
"updated_at": "2026-01-01T04:22:28.197Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
1592
backups/config/20260113/seed_keywords.json
Normal file
1592
backups/config/20260113/seed_keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user