website/.forgejo/workflows/scheduled-pricing-tests.yml
Tobias Brunner 78f52ea7f4
Some checks failed
Pricing Tests / Pricing Model Tests (push) Failing after 4s
Pricing Tests / Pricing Documentation Check (push) Failing after 3s
tests and actions
2025-06-20 10:46:11 +02:00

492 lines
19 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
}