493 lines
19 KiB
YAML
493 lines
19 KiB
YAML
|
name: Scheduled Pricing Tests
|
|||
|
|
|||
|
on:
|
|||
|
schedule:
|
|||
|
# Run daily at 6 AM UTC
|
|||
|
- cron: "0 6 * * *"
|
|||
|
workflow_dispatch:
|
|||
|
inputs:
|
|||
|
test_scope:
|
|||
|
description: "Test scope"
|
|||
|
required: true
|
|||
|
default: "all"
|
|||
|
type: choice
|
|||
|
options:
|
|||
|
- all
|
|||
|
- pricing-only
|
|||
|
- integration-only
|
|||
|
|
|||
|
jobs:
|
|||
|
scheduled-pricing-tests:
|
|||
|
name: Scheduled Pricing Validation
|
|||
|
runs-on: ubuntu-latest
|
|||
|
|
|||
|
strategy:
|
|||
|
matrix:
|
|||
|
database: ["sqlite", "postgresql"]
|
|||
|
fail-fast: false
|
|||
|
|
|||
|
services:
|
|||
|
postgres:
|
|||
|
image: postgres:15
|
|||
|
env:
|
|||
|
POSTGRES_PASSWORD: postgres
|
|||
|
POSTGRES_DB: servala_test
|
|||
|
options: >-
|
|||
|
--health-cmd pg_isready
|
|||
|
--health-interval 10s
|
|||
|
--health-timeout 5s
|
|||
|
--health-retries 5
|
|||
|
ports:
|
|||
|
- 5432:5432
|
|||
|
|
|||
|
steps:
|
|||
|
- name: Checkout code
|
|||
|
uses: actions/checkout@v4
|
|||
|
|
|||
|
- name: Set up Python
|
|||
|
uses: actions/setup-python@v4
|
|||
|
with:
|
|||
|
python-version: "3.13"
|
|||
|
|
|||
|
- name: Install uv
|
|||
|
uses: astral-sh/setup-uv@v3
|
|||
|
with:
|
|||
|
enable-cache: true
|
|||
|
cache-dependency-glob: "uv.lock"
|
|||
|
|
|||
|
- name: Install dependencies
|
|||
|
run: |
|
|||
|
uv sync --extra dev
|
|||
|
|
|||
|
- name: Set database configuration
|
|||
|
run: |
|
|||
|
if [ "${{ matrix.database }}" == "postgresql" ]; then
|
|||
|
echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/servala_test" >> $GITHUB_ENV
|
|||
|
else
|
|||
|
echo "DATABASE_URL=sqlite:///tmp/test.db" >> $GITHUB_ENV
|
|||
|
fi
|
|||
|
|
|||
|
- name: Run comprehensive pricing tests
|
|||
|
env:
|
|||
|
DJANGO_SETTINGS_MODULE: hub.settings
|
|||
|
run: |
|
|||
|
echo "::group::Running comprehensive pricing test suite on ${{ matrix.database }}"
|
|||
|
|
|||
|
# Set test scope based on input or default to all
|
|||
|
TEST_SCOPE="${{ github.event.inputs.test_scope || 'all' }}"
|
|||
|
|
|||
|
case $TEST_SCOPE in
|
|||
|
"pricing-only")
|
|||
|
echo "🎯 Running pricing-specific tests only"
|
|||
|
uv run --extra dev manage.py test \
|
|||
|
hub.services.tests.test_pricing \
|
|||
|
--verbosity=2 \
|
|||
|
--keepdb
|
|||
|
;;
|
|||
|
"integration-only")
|
|||
|
echo "🔗 Running integration tests only"
|
|||
|
uv run --extra dev manage.py test \
|
|||
|
hub.services.tests.test_pricing_integration \
|
|||
|
--verbosity=2 \
|
|||
|
--keepdb
|
|||
|
;;
|
|||
|
*)
|
|||
|
echo "🧪 Running all pricing tests"
|
|||
|
uv run --extra dev manage.py test \
|
|||
|
hub.services.tests.test_pricing \
|
|||
|
hub.services.tests.test_pricing_edge_cases \
|
|||
|
hub.services.tests.test_pricing_integration \
|
|||
|
--verbosity=2 \
|
|||
|
--keepdb
|
|||
|
;;
|
|||
|
esac
|
|||
|
|
|||
|
echo "::endgroup::"
|
|||
|
|
|||
|
- name: Run pricing stress tests
|
|||
|
env:
|
|||
|
DJANGO_SETTINGS_MODULE: hub.settings
|
|||
|
run: |
|
|||
|
echo "::group::Running pricing stress tests"
|
|||
|
|
|||
|
cat << 'EOF' > stress_test_pricing.py
|
|||
|
import os
|
|||
|
import django
|
|||
|
import time
|
|||
|
import concurrent.futures
|
|||
|
from decimal import Decimal
|
|||
|
|
|||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hub.settings')
|
|||
|
django.setup()
|
|||
|
|
|||
|
from hub.services.models.base import Currency, Term
|
|||
|
from hub.services.models.providers import CloudProvider
|
|||
|
from hub.services.models.services import Service
|
|||
|
from hub.services.models.pricing import (
|
|||
|
VSHNAppCatPrice, VSHNAppCatBaseFee, VSHNAppCatUnitRate,
|
|||
|
ProgressiveDiscountModel, DiscountTier
|
|||
|
)
|
|||
|
|
|||
|
def setup_test_data():
|
|||
|
"""Set up test data for stress testing"""
|
|||
|
provider = CloudProvider.objects.create(
|
|||
|
name="Stress Test Provider",
|
|||
|
slug="stress-test",
|
|||
|
description="Test",
|
|||
|
website="https://test.com"
|
|||
|
)
|
|||
|
|
|||
|
service = Service.objects.create(
|
|||
|
name="Stress Test Service",
|
|||
|
slug="stress-test",
|
|||
|
description="Test",
|
|||
|
features="Test"
|
|||
|
)
|
|||
|
|
|||
|
# Create complex discount model
|
|||
|
discount = ProgressiveDiscountModel.objects.create(
|
|||
|
name="Stress Test Discount",
|
|||
|
active=True
|
|||
|
)
|
|||
|
|
|||
|
# Create multiple discount tiers
|
|||
|
for i in range(0, 1000, 100):
|
|||
|
DiscountTier.objects.create(
|
|||
|
discount_model=discount,
|
|||
|
min_units=i,
|
|||
|
max_units=i+100 if i < 900 else None,
|
|||
|
discount_percent=Decimal(str(min(25, i/40)))
|
|||
|
)
|
|||
|
|
|||
|
price_config = VSHNAppCatPrice.objects.create(
|
|||
|
service=service,
|
|||
|
variable_unit='RAM',
|
|||
|
term='MTH',
|
|||
|
discount_model=discount
|
|||
|
)
|
|||
|
|
|||
|
VSHNAppCatBaseFee.objects.create(
|
|||
|
vshn_appcat_price_config=price_config,
|
|||
|
currency='CHF',
|
|||
|
amount=Decimal('100.00')
|
|||
|
)
|
|||
|
|
|||
|
VSHNAppCatUnitRate.objects.create(
|
|||
|
vshn_appcat_price_config=price_config,
|
|||
|
currency='CHF',
|
|||
|
service_level='GA',
|
|||
|
amount=Decimal('2.0000')
|
|||
|
)
|
|||
|
|
|||
|
return price_config
|
|||
|
|
|||
|
def calculate_price_concurrent(price_config, units):
|
|||
|
"""Calculate price in a concurrent context"""
|
|||
|
try:
|
|||
|
result = price_config.calculate_final_price('CHF', 'GA', units)
|
|||
|
return result['total_price'] if result else None
|
|||
|
except Exception as e:
|
|||
|
return f"Error: {e}"
|
|||
|
|
|||
|
def main():
|
|||
|
print("🚀 Starting pricing stress test...")
|
|||
|
|
|||
|
# Setup
|
|||
|
price_config = setup_test_data()
|
|||
|
|
|||
|
# Test scenarios with increasing complexity
|
|||
|
test_scenarios = [100, 500, 1000, 2000, 5000]
|
|||
|
|
|||
|
print("\n📊 Sequential performance test:")
|
|||
|
for units in test_scenarios:
|
|||
|
start_time = time.time()
|
|||
|
result = price_config.calculate_final_price('CHF', 'GA', units)
|
|||
|
end_time = time.time()
|
|||
|
|
|||
|
duration = end_time - start_time
|
|||
|
print(f" {units:4d} units: {duration:.3f}s -> {result['total_price']} CHF")
|
|||
|
|
|||
|
if duration > 2.0:
|
|||
|
print(f"⚠️ Performance warning: {units} units took {duration:.3f}s")
|
|||
|
|
|||
|
print("\n🔄 Concurrent performance test:")
|
|||
|
start_time = time.time()
|
|||
|
|
|||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|||
|
futures = []
|
|||
|
for _ in range(50): # 50 concurrent calculations
|
|||
|
future = executor.submit(calculate_price_concurrent, price_config, 1000)
|
|||
|
futures.append(future)
|
|||
|
|
|||
|
results = []
|
|||
|
for future in concurrent.futures.as_completed(futures):
|
|||
|
result = future.result()
|
|||
|
results.append(result)
|
|||
|
|
|||
|
end_time = time.time()
|
|||
|
duration = end_time - start_time
|
|||
|
|
|||
|
successful_results = [r for r in results if isinstance(r, Decimal)]
|
|||
|
failed_results = [r for r in results if not isinstance(r, Decimal)]
|
|||
|
|
|||
|
print(f" 50 concurrent calculations: {duration:.3f}s")
|
|||
|
print(f" Successful: {len(successful_results)}")
|
|||
|
print(f" Failed: {len(failed_results)}")
|
|||
|
|
|||
|
if failed_results:
|
|||
|
print(f" Failures: {failed_results[:3]}...") # Show first 3 failures
|
|||
|
|
|||
|
# Validate results
|
|||
|
if len(successful_results) < 45: # Allow up to 10% failures
|
|||
|
raise Exception(f"Too many concurrent calculation failures: {len(failed_results)}")
|
|||
|
|
|||
|
if duration > 10.0: # Should complete within 10 seconds
|
|||
|
raise Exception(f"Concurrent calculations too slow: {duration}s")
|
|||
|
|
|||
|
print("\n✅ Stress test completed successfully!")
|
|||
|
|
|||
|
if __name__ == "__main__":
|
|||
|
main()
|
|||
|
EOF
|
|||
|
|
|||
|
uv run python stress_test_pricing.py
|
|||
|
echo "::endgroup::"
|
|||
|
|
|||
|
- name: Validate pricing data integrity
|
|||
|
env:
|
|||
|
DJANGO_SETTINGS_MODULE: hub.settings
|
|||
|
run: |
|
|||
|
echo "::group::Validating pricing data integrity"
|
|||
|
|
|||
|
cat << 'EOF' > integrity_check.py
|
|||
|
import os
|
|||
|
import django
|
|||
|
from decimal import Decimal, InvalidOperation
|
|||
|
|
|||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hub.settings')
|
|||
|
django.setup()
|
|||
|
|
|||
|
from django.db import connection
|
|||
|
from hub.services.models.pricing import *
|
|||
|
|
|||
|
def check_pricing_constraints():
|
|||
|
"""Check database constraints and data integrity"""
|
|||
|
issues = []
|
|||
|
|
|||
|
print("🔍 Checking pricing data integrity...")
|
|||
|
|
|||
|
# Check for negative prices
|
|||
|
negative_compute_prices = ComputePlanPrice.objects.filter(amount__lt=0)
|
|||
|
if negative_compute_prices.exists():
|
|||
|
issues.append(f"Found {negative_compute_prices.count()} negative compute plan prices")
|
|||
|
|
|||
|
negative_storage_prices = StoragePlanPrice.objects.filter(amount__lt=0)
|
|||
|
if negative_storage_prices.exists():
|
|||
|
issues.append(f"Found {negative_storage_prices.count()} negative storage prices")
|
|||
|
|
|||
|
# Check for invalid discount percentages
|
|||
|
invalid_discounts = DiscountTier.objects.filter(
|
|||
|
models.Q(discount_percent__lt=0) | models.Q(discount_percent__gt=100)
|
|||
|
)
|
|||
|
if invalid_discounts.exists():
|
|||
|
issues.append(f"Found {invalid_discounts.count()} invalid discount percentages")
|
|||
|
|
|||
|
# Check for overlapping discount tiers (potential logic issues)
|
|||
|
discount_models = ProgressiveDiscountModel.objects.filter(active=True)
|
|||
|
for model in discount_models:
|
|||
|
tiers = model.tiers.all().order_by('min_units')
|
|||
|
for i in range(len(tiers) - 1):
|
|||
|
current = tiers[i]
|
|||
|
next_tier = tiers[i + 1]
|
|||
|
|
|||
|
if current.max_units and current.max_units > next_tier.min_units:
|
|||
|
issues.append(f"Overlapping tiers in {model.name}: {current.min_units}-{current.max_units} overlaps with {next_tier.min_units}")
|
|||
|
|
|||
|
# Check for services without pricing
|
|||
|
services_without_pricing = Service.objects.filter(vshn_appcat_price__isnull=True)
|
|||
|
if services_without_pricing.exists():
|
|||
|
print(f"ℹ️ Found {services_without_pricing.count()} services without AppCat pricing (this may be normal)")
|
|||
|
|
|||
|
# Check for price configurations without rates
|
|||
|
price_configs_without_base_fee = VSHNAppCatPrice.objects.filter(base_fees__isnull=True)
|
|||
|
if price_configs_without_base_fee.exists():
|
|||
|
issues.append(f"Found {price_configs_without_base_fee.count()} price configs without base fees")
|
|||
|
|
|||
|
return issues
|
|||
|
|
|||
|
def main():
|
|||
|
issues = check_pricing_constraints()
|
|||
|
|
|||
|
if issues:
|
|||
|
print("\n❌ Data integrity issues found:")
|
|||
|
for issue in issues:
|
|||
|
print(f" - {issue}")
|
|||
|
print(f"\nTotal issues: {len(issues)}")
|
|||
|
|
|||
|
# Don't fail the build for minor issues, but warn
|
|||
|
if len(issues) > 5:
|
|||
|
print("⚠️ Many integrity issues found - consider investigating")
|
|||
|
exit(1)
|
|||
|
else:
|
|||
|
print("\n✅ All pricing data integrity checks passed!")
|
|||
|
|
|||
|
if __name__ == "__main__":
|
|||
|
main()
|
|||
|
EOF
|
|||
|
|
|||
|
uv run python integrity_check.py
|
|||
|
echo "::endgroup::"
|
|||
|
|
|||
|
- name: Generate daily pricing report
|
|||
|
env:
|
|||
|
DJANGO_SETTINGS_MODULE: hub.settings
|
|||
|
run: |
|
|||
|
echo "::group::Generating daily pricing report"
|
|||
|
|
|||
|
cat << 'EOF' > daily_report.py
|
|||
|
import os
|
|||
|
import django
|
|||
|
from decimal import Decimal
|
|||
|
from datetime import datetime
|
|||
|
|
|||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hub.settings')
|
|||
|
django.setup()
|
|||
|
|
|||
|
from hub.services.models.pricing import *
|
|||
|
from hub.services.models.services import Service
|
|||
|
from hub.services.models.providers import CloudProvider
|
|||
|
|
|||
|
def generate_report():
|
|||
|
print("📊 Daily Pricing System Report")
|
|||
|
print("=" * 50)
|
|||
|
print(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|||
|
print(f"Database: ${{ matrix.database }}")
|
|||
|
print()
|
|||
|
|
|||
|
# Count models
|
|||
|
print("📈 Model Counts:")
|
|||
|
print(f" Cloud Providers: {CloudProvider.objects.count()}")
|
|||
|
print(f" Services: {Service.objects.count()}")
|
|||
|
print(f" Compute Plans: {ComputePlan.objects.count()}")
|
|||
|
print(f" Storage Plans: {StoragePlan.objects.count()}")
|
|||
|
print(f" AppCat Price Configs: {VSHNAppCatPrice.objects.count()}")
|
|||
|
print(f" Discount Models: {ProgressiveDiscountModel.objects.count()}")
|
|||
|
print(f" Active Discount Models: {ProgressiveDiscountModel.objects.filter(active=True).count()}")
|
|||
|
print()
|
|||
|
|
|||
|
# Price ranges
|
|||
|
print("💰 Price Ranges:")
|
|||
|
|
|||
|
compute_prices = ComputePlanPrice.objects.all()
|
|||
|
if compute_prices.exists():
|
|||
|
min_compute = compute_prices.order_by('amount').first().amount
|
|||
|
max_compute = compute_prices.order_by('-amount').first().amount
|
|||
|
print(f" Compute Plans: {min_compute} - {max_compute} CHF")
|
|||
|
|
|||
|
base_fees = VSHNAppCatBaseFee.objects.all()
|
|||
|
if base_fees.exists():
|
|||
|
min_base = base_fees.order_by('amount').first().amount
|
|||
|
max_base = base_fees.order_by('-amount').first().amount
|
|||
|
print(f" AppCat Base Fees: {min_base} - {max_base} CHF")
|
|||
|
|
|||
|
unit_rates = VSHNAppCatUnitRate.objects.all()
|
|||
|
if unit_rates.exists():
|
|||
|
min_unit = unit_rates.order_by('amount').first().amount
|
|||
|
max_unit = unit_rates.order_by('-amount').first().amount
|
|||
|
print(f" AppCat Unit Rates: {min_unit} - {max_unit} CHF")
|
|||
|
print()
|
|||
|
|
|||
|
# Currency distribution
|
|||
|
print("💱 Currency Distribution:")
|
|||
|
currencies = ['CHF', 'EUR', 'USD']
|
|||
|
for currency in currencies:
|
|||
|
compute_count = ComputePlanPrice.objects.filter(currency=currency).count()
|
|||
|
appcat_count = VSHNAppCatBaseFee.objects.filter(currency=currency).count()
|
|||
|
print(f" {currency}: {compute_count} compute prices, {appcat_count} AppCat base fees")
|
|||
|
print()
|
|||
|
|
|||
|
# Discount model analysis
|
|||
|
print("🎯 Discount Model Analysis:")
|
|||
|
active_discounts = ProgressiveDiscountModel.objects.filter(active=True)
|
|||
|
for discount in active_discounts[:5]: # Show first 5
|
|||
|
tier_count = discount.tiers.count()
|
|||
|
max_discount = discount.tiers.order_by('-discount_percent').first()
|
|||
|
max_percent = max_discount.discount_percent if max_discount else 0
|
|||
|
print(f" {discount.name}: {tier_count} tiers, max {max_percent}% discount")
|
|||
|
|
|||
|
if active_discounts.count() > 5:
|
|||
|
print(f" ... and {active_discounts.count() - 5} more")
|
|||
|
print()
|
|||
|
|
|||
|
print("✅ Report generation completed")
|
|||
|
|
|||
|
if __name__ == "__main__":
|
|||
|
generate_report()
|
|||
|
EOF
|
|||
|
|
|||
|
uv run python daily_report.py
|
|||
|
echo "::endgroup::"
|
|||
|
|
|||
|
- name: Save test results
|
|||
|
if: always()
|
|||
|
uses: actions/upload-artifact@v4
|
|||
|
with:
|
|||
|
name: scheduled-test-results-${{ matrix.database }}
|
|||
|
path: |
|
|||
|
htmlcov/
|
|||
|
test-results.xml
|
|||
|
retention-days: 30
|
|||
|
|
|||
|
notify-on-failure:
|
|||
|
name: Notify on Test Failure
|
|||
|
runs-on: ubuntu-latest
|
|||
|
needs: [scheduled-pricing-tests]
|
|||
|
if: failure()
|
|||
|
|
|||
|
steps:
|
|||
|
- name: Create failure issue
|
|||
|
uses: actions/github-script@v7
|
|||
|
with:
|
|||
|
script: |
|
|||
|
const title = `🚨 Scheduled Pricing Tests Failed - ${new Date().toISOString().split('T')[0]}`;
|
|||
|
const body = `
|
|||
|
## Scheduled Pricing Test Failure
|
|||
|
|
|||
|
The scheduled pricing tests failed on ${new Date().toISOString()}.
|
|||
|
|
|||
|
**Run Details:**
|
|||
|
- **Workflow**: ${context.workflow}
|
|||
|
- **Run ID**: ${context.runId}
|
|||
|
- **Commit**: ${context.sha}
|
|||
|
|
|||
|
**Next Steps:**
|
|||
|
1. Check the workflow logs for detailed error information
|
|||
|
2. Verify if this is a transient issue by re-running the workflow
|
|||
|
3. If the issue persists, investigate potential regressions
|
|||
|
|
|||
|
**Links:**
|
|||
|
- [Failed Workflow Run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})
|
|||
|
|
|||
|
/cc @tobru
|
|||
|
`;
|
|||
|
|
|||
|
// Check if similar issue already exists
|
|||
|
const existingIssues = await github.rest.issues.listForRepo({
|
|||
|
owner: context.repo.owner,
|
|||
|
repo: context.repo.repo,
|
|||
|
labels: 'pricing-tests,automated',
|
|||
|
state: 'open'
|
|||
|
});
|
|||
|
|
|||
|
if (existingIssues.data.length === 0) {
|
|||
|
await github.rest.issues.create({
|
|||
|
owner: context.repo.owner,
|
|||
|
repo: context.repo.repo,
|
|||
|
title: title,
|
|||
|
body: body,
|
|||
|
labels: ['bug', 'pricing-tests', 'automated', 'priority-high']
|
|||
|
});
|
|||
|
} else {
|
|||
|
console.log('Similar issue already exists, skipping creation');
|
|||
|
}
|