359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""
|
||
API-based Carpenter Quote Test
|
||
Tests quote creation through API calls with UI verification
|
||
This is more reliable than pure UI testing
|
||
"""
|
||
|
||
import requests
|
||
import json
|
||
import time
|
||
from datetime import datetime
|
||
|
||
class CarpenterQuoteAPITest:
|
||
def __init__(self):
|
||
self.api_url = 'http://localhost:4031'
|
||
self.test_data = {
|
||
'customer_name': f'Test Tømrerkunde API - {datetime.now().strftime("%Y%m%d-%H%M%S")}',
|
||
'customer_email': 'test-api@toemrer.dk',
|
||
'customer_phone': '42 46 81 10',
|
||
'customer_address': 'API Testvej 123, 4600 Køge',
|
||
'project_description': 'Terrasse 30 m² med douglasgran brædder',
|
||
}
|
||
self.project_id = None
|
||
self.results = {}
|
||
|
||
def run_all_tests(self):
|
||
"""Run all tests"""
|
||
print('\n' + '='*60)
|
||
print('🔨 CARPENTER QUOTE API TESTS')
|
||
print('='*60 + '\n')
|
||
|
||
try:
|
||
self.test_01_api_health()
|
||
self.test_02_create_project()
|
||
self.test_03_add_geometry()
|
||
self.test_04_get_smart_packages()
|
||
self.test_05_add_materials()
|
||
self.test_06_calculate_totals()
|
||
self.test_07_verify_realism()
|
||
self.test_08_save_results()
|
||
|
||
print('\n' + '='*60)
|
||
print('✅ ALL TESTS PASSED')
|
||
print('='*60 + '\n')
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f'\n❌ TEST FAILED: {e}\n')
|
||
return False
|
||
|
||
def test_01_api_health(self):
|
||
"""Test 1: API Health Check"""
|
||
print('🏥 Test 1: API Health Check')
|
||
|
||
response = requests.get(f'{self.api_url}/health')
|
||
assert response.status_code == 200, f'Health check failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
print(f' ✅ API Status: {data.get("status")}')
|
||
print(f' ✅ Service: {data.get("service")}')
|
||
|
||
def test_02_create_project(self):
|
||
"""Test 2: Create Customer Project"""
|
||
print('\n🏗️ Test 2: Create Customer Project')
|
||
|
||
payload = {
|
||
'projectName': 'Terrasse Projekt via API',
|
||
'customerName': self.test_data['customer_name'],
|
||
'address': self.test_data['customer_address'],
|
||
'zipcode': '4600',
|
||
'city': 'Køge'
|
||
}
|
||
|
||
response = requests.post(
|
||
f'{self.api_url}/api/customer-projects/projects',
|
||
json=payload
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
print(f' ❌ Error response: {response.text}')
|
||
|
||
assert response.status_code == 200, f'Create project failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
assert data.get('success'), 'Project creation was not successful'
|
||
|
||
self.project_id = data['project']['id']
|
||
|
||
print(f' ✅ Project created: ID {self.project_id}')
|
||
print(f' ✅ Customer: {self.test_data["customer_name"]}')
|
||
|
||
self.results['project_id'] = self.project_id
|
||
self.results['customer'] = self.test_data
|
||
|
||
def test_03_add_geometry(self):
|
||
"""Test 3: Add Geometry Data"""
|
||
print('\n📐 Test 3: Add Geometry Data')
|
||
|
||
geometry_data = {
|
||
'roofType': 'fladt_tag',
|
||
'roofWidth': 5.0,
|
||
'roofLength': 6.0,
|
||
'wallHeight': 2.5,
|
||
'roofPitch': 2.0, # Fladt tag har lav hældning
|
||
'complexity': 1.0
|
||
}
|
||
|
||
print(f' 📤 Sending payload: {json.dumps(geometry_data, indent=2)}')
|
||
|
||
response = requests.post(
|
||
f'{self.api_url}/api/customer-projects/{self.project_id}/geometry',
|
||
json=geometry_data
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
print(f' ❌ Error response: {response.text}')
|
||
|
||
assert response.status_code == 200, f'Add geometry failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
assert data.get('success'), 'Geometry addition was not successful'
|
||
|
||
total_area = geometry_data['roofWidth'] * geometry_data['roofLength']
|
||
print(f' ✅ Geometry added: {total_area:.1f} m²')
|
||
print(f' ✅ Dimensions: {geometry_data["roofLength"]}m × {geometry_data["roofWidth"]}m')
|
||
print(f' ✅ Wall height: {geometry_data["wallHeight"]}m')
|
||
|
||
self.results['geometry'] = geometry_data
|
||
|
||
def test_04_get_smart_packages(self):
|
||
"""Test 4: Get Available Smart Packages"""
|
||
print('\n🔧 Test 4: Get Smart Packages')
|
||
|
||
response = requests.get(f'{self.api_url}/api/smart-packages')
|
||
|
||
assert response.status_code == 200, f'Get packages failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
packages = data.get('packages', [])
|
||
|
||
print(f' ✅ Found {len(packages)} smart packages')
|
||
|
||
if packages:
|
||
# Show first few packages
|
||
for pkg in packages[:3]:
|
||
print(f' • {pkg.get("name")} ({pkg.get("category")})')
|
||
|
||
self.results['available_packages'] = len(packages)
|
||
|
||
def test_05_add_materials(self):
|
||
"""Test 5: Add Materials to Project"""
|
||
print('\n🧱 Test 5: Add Materials')
|
||
|
||
# Typical materials for a 30m² terrace - use camelCase for server
|
||
materials = [
|
||
{
|
||
'materialName': 'Douglasgran terrassebrædder 28x145mm',
|
||
'quantity': 35,
|
||
'unit': 'm',
|
||
'unitPrice': 75.50,
|
||
'supplier': 'Bygma',
|
||
'materialCategory': 'Træ'
|
||
},
|
||
{
|
||
'materialName': 'Terrasseunderkonstruktion 45x95mm',
|
||
'quantity': 45,
|
||
'unit': 'm',
|
||
'unitPrice': 45.00,
|
||
'supplier': 'Bygma',
|
||
'materialCategory': 'Træ'
|
||
},
|
||
{
|
||
'materialName': 'Rustfrie terrasseskruer 4.5x50mm',
|
||
'quantity': 2,
|
||
'unit': 'pakke',
|
||
'unitPrice': 245.00,
|
||
'supplier': 'Bygma',
|
||
'materialCategory': 'Beslag'
|
||
},
|
||
{
|
||
'materialName': 'Terrassebeslag og fødder',
|
||
'quantity': 24,
|
||
'unit': 'stk',
|
||
'unitPrice': 15.50,
|
||
'supplier': 'Bygma',
|
||
'materialCategory': 'Beslag'
|
||
},
|
||
{
|
||
'materialName': 'Træbeskyttelse Douglasgran olie',
|
||
'quantity': 2,
|
||
'unit': 'liter',
|
||
'unitPrice': 185.00,
|
||
'supplier': 'Bauhaus',
|
||
'materialCategory': 'Maling'
|
||
}
|
||
]
|
||
|
||
# Add all materials in one request as server expects array
|
||
response = requests.post(
|
||
f'{self.api_url}/api/customer-projects/{self.project_id}/materials',
|
||
json={'materials': materials}
|
||
)
|
||
|
||
assert response.status_code == 200, f'Add materials failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
assert data.get('success'), 'Materials addition was not successful'
|
||
|
||
# Calculate total
|
||
total_material_cost = sum(m['quantity'] * m['unitPrice'] for m in materials)
|
||
|
||
print(f' ✅ Added {len(materials)} materials')
|
||
for material in materials:
|
||
material_cost = material['quantity'] * material['unitPrice']
|
||
print(f' • {material["materialName"]}: {material_cost:,.0f} kr')
|
||
|
||
print(f'\n 💰 Total materials: {len(materials)} items = {total_material_cost:,.0f} kr')
|
||
|
||
self.results['materials'] = {
|
||
'count': len(materials),
|
||
'total_cost': total_material_cost,
|
||
'items': materials
|
||
}
|
||
|
||
def test_06_calculate_totals(self):
|
||
"""Test 6: Calculate Project Totals"""
|
||
print('\n💰 Test 6: Calculate Totals')
|
||
|
||
# Get project with all data
|
||
response = requests.get(f'{self.api_url}/api/customer-projects/{self.project_id}')
|
||
|
||
assert response.status_code == 200, f'Get project failed: {response.status_code}'
|
||
|
||
data = response.json()
|
||
project = data.get('project')
|
||
|
||
# Calculate from materials
|
||
materials = project.get('materials', [])
|
||
material_total = sum(
|
||
float(m.get('quantity', 0)) * float(m.get('unit_price', 0))
|
||
for m in materials
|
||
)
|
||
|
||
# Estimate labor (typical 25-30 hours for 30m² terrace)
|
||
estimated_hours = 28
|
||
hourly_rate = 500
|
||
labor_total = estimated_hours * hourly_rate
|
||
|
||
# Totals
|
||
subtotal = material_total + labor_total
|
||
vat = subtotal * 0.25
|
||
total_incl_vat = subtotal + vat
|
||
|
||
print(f' 📦 Materials: {material_total:,.0f} kr ({len(materials)} items)')
|
||
print(f' ⏱️ Labor: {labor_total:,.0f} kr ({estimated_hours} timer × {hourly_rate} kr/t)')
|
||
print(f' 💵 Subtotal: {subtotal:,.0f} kr')
|
||
print(f' 📊 Moms (25%): {vat:,.0f} kr')
|
||
print(f' 🎯 Total inkl. moms: {total_incl_vat:,.0f} kr')
|
||
|
||
self.results['pricing'] = {
|
||
'materials': material_total,
|
||
'labor_hours': estimated_hours,
|
||
'labor_rate': hourly_rate,
|
||
'labor_total': labor_total,
|
||
'subtotal': subtotal,
|
||
'vat': vat,
|
||
'total_incl_vat': total_incl_vat
|
||
}
|
||
|
||
def test_07_verify_realism(self):
|
||
"""Test 7: Verify Quote Realism"""
|
||
print('\n🔍 Test 7: Verify Quote Realism')
|
||
|
||
pricing = self.results.get('pricing', {})
|
||
materials = self.results.get('materials', {})
|
||
|
||
# Market comparison for 30m² terrace
|
||
market_price_per_m2 = 2500 # 2000-3000 kr/m²
|
||
market_total = 30 * market_price_per_m2 # ~75,000 kr
|
||
|
||
our_total = pricing.get('total_incl_vat', 0)
|
||
difference_pct = ((our_total - market_total) / market_total) * 100
|
||
|
||
checks = {
|
||
'Has materials': materials.get('count', 0) > 0,
|
||
'Material cost reasonable': 15000 < materials.get('total_cost', 0) < 40000,
|
||
'Labor hours reasonable': 20 < pricing.get('labor_hours', 0) < 40,
|
||
'Total price in market range': 60000 < our_total < 100000,
|
||
'Price deviation acceptable': abs(difference_pct) < 30,
|
||
'VAT calculated': pricing.get('vat', 0) > 0,
|
||
'All components present': all([
|
||
pricing.get('materials'),
|
||
pricing.get('labor_total'),
|
||
pricing.get('vat'),
|
||
pricing.get('total_incl_vat')
|
||
])
|
||
}
|
||
|
||
passed = sum(checks.values())
|
||
total = len(checks)
|
||
|
||
print(f'\n Realism Check: {passed}/{total} passed\n')
|
||
for check, result in checks.items():
|
||
print(f' {"✅" if result else "❌"} {check}')
|
||
|
||
print(f'\n 📊 Market comparison:')
|
||
print(f' Market price: ~{market_total:,.0f} kr (2500 kr/m²)')
|
||
print(f' Our quote: {our_total:,.0f} kr')
|
||
print(f' Difference: {difference_pct:+.1f}%')
|
||
|
||
if abs(difference_pct) < 10:
|
||
print(f' ✅ EXCELLENT - Very close to market price')
|
||
elif abs(difference_pct) < 20:
|
||
print(f' ✅ GOOD - Within reasonable range')
|
||
elif abs(difference_pct) < 30:
|
||
print(f' ⚠️ ACCEPTABLE - At edge of market range')
|
||
else:
|
||
print(f' ❌ CONCERN - Significantly different from market')
|
||
|
||
self.results['realism_check'] = {
|
||
'passed': passed,
|
||
'total': total,
|
||
'checks': checks,
|
||
'market_comparison': {
|
||
'market_price': market_total,
|
||
'our_price': our_total,
|
||
'difference_pct': difference_pct
|
||
}
|
||
}
|
||
|
||
assert passed >= total * 0.8, f'Only {passed}/{total} realism checks passed'
|
||
|
||
def test_08_save_results(self):
|
||
"""Test 8: Save Test Results"""
|
||
print('\n💾 Test 8: Save Results')
|
||
|
||
results_file = 'test-results/api-quote-results.json'
|
||
|
||
import os
|
||
os.makedirs('test-results', exist_ok=True)
|
||
|
||
with open(results_file, 'w', encoding='utf-8') as f:
|
||
json.dump(self.results, f, indent=2, ensure_ascii=False, default=str)
|
||
|
||
print(f' ✅ Results saved to: {results_file}')
|
||
print(f' ✅ Project ID: {self.project_id}')
|
||
print(f' ✅ Total price: {self.results["pricing"]["total_incl_vat"]:,.0f} kr')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
test = CarpenterQuoteAPITest()
|
||
success = test.run_all_tests()
|
||
|
||
if success:
|
||
print('\n🎉 All tests passed! Quote is realistic and production-ready.\n')
|
||
exit(0)
|
||
else:
|
||
print('\n❌ Some tests failed.\n')
|
||
exit(1)
|