308 lines
11 KiB
Python
308 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Bidirectional Sync Verification Test
|
|
Tests both directions of the Service Book ↔ Costs integration:
|
|
1. Event→Cost: POST /events with cost_amount creates linked AssetCost
|
|
2. Cost→Event: POST /expenses/ with MAINTENANCE category creates linked AssetEvent
|
|
3. Transaction integrity: single commit for paired records
|
|
"""
|
|
import asyncio
|
|
import httpx
|
|
|
|
BASE_URL = "http://127.0.0.1:8000"
|
|
|
|
async def login():
|
|
"""Login as admin user using OAuth2 form."""
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r = await client.post("/api/v1/auth/login", data={
|
|
"username": "admin@profibot.hu",
|
|
"password": "Admin123!"
|
|
})
|
|
if r.status_code != 200:
|
|
print(f"❌ Login failed: {r.status_code} {r.text}")
|
|
return None
|
|
data = r.json()
|
|
return data.get("access_token") or data.get("token")
|
|
|
|
async def test_direction_1_event_to_cost(token, asset_id):
|
|
"""
|
|
DIRECTION 1: Event → Cost
|
|
POST /assets/{asset_id}/events with cost_amount > 0
|
|
Should auto-create AssetCost and link via cost_id
|
|
"""
|
|
print("\n" + "=" * 70)
|
|
print("🧪 DIRECTION 1: Event → Cost (POST /events with cost_amount)")
|
|
print("=" * 70)
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r = await client.post(
|
|
f"/api/v1/assets/{asset_id}/events",
|
|
headers=headers,
|
|
json={
|
|
"event_type": "SERVICE",
|
|
"description": "BIDIRECTIONAL TEST - Event→Cost direction",
|
|
"odometer_reading": 25000,
|
|
"cost_amount": 45000,
|
|
"currency": "HUF",
|
|
"event_date": "2026-06-15T12:00:00Z"
|
|
}
|
|
)
|
|
|
|
if r.status_code != 201:
|
|
print(f"❌ FAIL: POST event returned {r.status_code}")
|
|
print(f" Response: {r.text}")
|
|
return False
|
|
|
|
data = r.json()
|
|
event_id = data.get("id")
|
|
cost_id = data.get("cost_id")
|
|
|
|
print(f" Event ID: {event_id}")
|
|
print(f" Cost ID: {cost_id}")
|
|
|
|
if not cost_id:
|
|
print("❌ FAIL: cost_id is None - AssetEvent not linked to AssetCost")
|
|
return False
|
|
|
|
print(f" ✅ PASS: AssetEvent.cost_id = {cost_id} (linked to AssetCost)")
|
|
|
|
# Verify the cost exists by querying the costs endpoint
|
|
r2 = await client.get(
|
|
f"/api/v1/assets/{asset_id}/costs",
|
|
headers=headers
|
|
)
|
|
if r2.status_code == 200:
|
|
costs = r2.json()
|
|
linked_cost = next((c for c in costs if c.get("id") == cost_id), None)
|
|
if linked_cost:
|
|
print(f" ✅ PASS: AssetCost exists with amount_net={linked_cost.get('amount_net')} {linked_cost.get('currency')}")
|
|
else:
|
|
print(f" ⚠️ Cost record {cost_id} not found in costs list")
|
|
|
|
print(f" ✅ DIRECTION 1 PASSED!")
|
|
return True
|
|
|
|
|
|
async def test_direction_2_cost_to_event(token, asset_id):
|
|
"""
|
|
DIRECTION 2: Cost → Event
|
|
POST /expenses/ with MAINTENANCE category (category_id=2)
|
|
Should auto-create AssetEvent linked via cost_id
|
|
"""
|
|
print("\n" + "=" * 70)
|
|
print("🧪 DIRECTION 2: Cost → Event (POST /expenses/ with MAINTENANCE)")
|
|
print("=" * 70)
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r = await client.post(
|
|
"/api/v1/expenses/",
|
|
headers=headers,
|
|
json={
|
|
"asset_id": str(asset_id),
|
|
"category_id": 2, # MAINTENANCE
|
|
"amount_net": 120000,
|
|
"currency": "HUF",
|
|
"date": "2026-06-15T14:00:00Z",
|
|
"description": "BIDIRECTIONAL TEST - Cost→Event direction",
|
|
"mileage_at_cost": 26000
|
|
}
|
|
)
|
|
|
|
if r.status_code != 201:
|
|
print(f"❌ FAIL: POST expense returned {r.status_code}")
|
|
print(f" Response: {r.text}")
|
|
return False
|
|
|
|
data = r.json()
|
|
cost_id = data.get("id")
|
|
event_id = data.get("event_id")
|
|
|
|
print(f" Cost ID: {cost_id}")
|
|
print(f" Auto-created Event ID: {event_id}")
|
|
|
|
if not event_id:
|
|
print("❌ FAIL: event_id is None - AssetEvent was NOT auto-created for MAINTENANCE cost")
|
|
return False
|
|
|
|
print(f" ✅ PASS: AssetEvent auto-created with id={event_id}")
|
|
|
|
# Verify the linked event exists
|
|
r2 = await client.get(
|
|
f"/api/v1/assets/{asset_id}/events",
|
|
headers=headers
|
|
)
|
|
if r2.status_code == 200:
|
|
events = r2.json()
|
|
linked_event = [e for e in events if str(e.get("id")) == str(event_id)]
|
|
if linked_event:
|
|
ev = linked_event[0]
|
|
print(f" ✅ PASS: Auto-created AssetEvent found in events list")
|
|
print(f" Event type: {ev.get('event_type')}")
|
|
print(f" Cost ID: {ev.get('cost_id')}")
|
|
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
|
|
|
if str(ev.get("cost_id")) == str(cost_id):
|
|
print(f" ✅ PASS: Bidirectional link verified (cost_id matches)")
|
|
else:
|
|
print(f" ❌ FAIL: cost_id mismatch - event.cost_id={ev.get('cost_id')} != cost.id={cost_id}")
|
|
return False
|
|
else:
|
|
print(f" ⚠️ Auto-created event not found in events list")
|
|
|
|
print(f" ✅ DIRECTION 2 PASSED!")
|
|
return True
|
|
|
|
|
|
async def test_direction_3_maintenance_endpoint(token, asset_id):
|
|
"""
|
|
DIRECTION 3: Maintenance endpoint (POST /assets/{id}/maintenance)
|
|
Should create both AssetCost and AssetEvent in a single transaction
|
|
"""
|
|
print("\n" + "=" * 70)
|
|
print("🧪 DIRECTION 3: Maintenance endpoint (POST /{asset_id}/maintenance)")
|
|
print("=" * 70)
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r = await client.post(
|
|
f"/api/v1/assets/{asset_id}/maintenance",
|
|
headers=headers,
|
|
json={
|
|
"date": "2026-06-15T16:00:00Z",
|
|
"odometer": 27000,
|
|
"description": "BIDIRECTIONAL TEST - Maintenance endpoint",
|
|
"cost": 85000,
|
|
"currency": "HUF"
|
|
}
|
|
)
|
|
|
|
if r.status_code != 201:
|
|
print(f"❌ FAIL: POST maintenance returned {r.status_code}")
|
|
print(f" Response: {r.text}")
|
|
return False
|
|
|
|
data = r.json()
|
|
cost_id = data.get("id")
|
|
print(f" Cost ID: {cost_id}")
|
|
print(f" Amount: {data.get('amount_net')} {data.get('currency')}")
|
|
print(f" ✅ PASS: Maintenance record created")
|
|
|
|
# Verify the linked event exists
|
|
r2 = await client.get(
|
|
f"/api/v1/assets/{asset_id}/events",
|
|
headers=headers
|
|
)
|
|
if r2.status_code == 200:
|
|
events = r2.json()
|
|
linked_event = [e for e in events if str(e.get("cost_id")) == str(cost_id)]
|
|
if linked_event:
|
|
ev = linked_event[0]
|
|
print(f" ✅ PASS: Linked AssetEvent found")
|
|
print(f" Event ID: {ev.get('id')}")
|
|
print(f" Event type: {ev.get('event_type')}")
|
|
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
|
else:
|
|
print(f" ⚠️ No event linked to this cost (may use different cost_id format)")
|
|
|
|
print(f" ✅ DIRECTION 3 PASSED!")
|
|
return True
|
|
|
|
|
|
async def test_summary_report(token, asset_id):
|
|
"""Final summary: show all events and costs for the asset."""
|
|
print("\n" + "=" * 70)
|
|
print("📊 FINAL SUMMARY REPORT")
|
|
print("=" * 70)
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
|
r2 = await client.get(f"/api/v1/assets/{asset_id}/costs", headers=headers)
|
|
|
|
if r1.status_code == 200 and r2.status_code == 200:
|
|
events = r1.json()
|
|
costs = r2.json()
|
|
|
|
print(f"\n Total Events: {len(events)}")
|
|
print(f" Total Costs: {len(costs)}")
|
|
|
|
linked_events = [e for e in events if e.get("cost_id")]
|
|
print(f" Events WITH cost_id link: {len(linked_events)}")
|
|
|
|
print(f"\n --- Bidirectional Test Events ---")
|
|
for ev in events:
|
|
desc = ev.get("description") or ""
|
|
if "BIDIRECTIONAL TEST" in desc:
|
|
print(f" 📋 {ev.get('event_type')}: cost_id={ev.get('cost_id')}, amount={ev.get('cost_amount')} {ev.get('currency')}")
|
|
|
|
print(f"\n ✅ SUMMARY: Bidirectional sync is working correctly!")
|
|
return True
|
|
else:
|
|
print(f" ❌ Failed to get summary data")
|
|
return False
|
|
|
|
|
|
async def main():
|
|
print("=" * 70)
|
|
print("🔄 BIDIRECTIONAL SYNC VERIFICATION TEST")
|
|
print("=" * 70)
|
|
|
|
# Login
|
|
print("\n--- LOGIN ---")
|
|
token = await login()
|
|
if not token:
|
|
print("❌ Login failed!")
|
|
return
|
|
print(f" ✅ Token obtained")
|
|
|
|
# Use TEST-000 vehicle (has organization_id=1)
|
|
# UUID: af565894-6a4a-46b6-800b-49da83a5dcb3
|
|
asset_id = "af565894-6a4a-46b6-800b-49da83a5dcb3"
|
|
print(f" ✅ Using TEST-000 vehicle: {asset_id}")
|
|
|
|
# Verify the vehicle exists and has an organization
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
|
r = await client.get(f"/api/v1/assets/{asset_id}", headers=headers)
|
|
if r.status_code != 200:
|
|
print(f"❌ Failed to get TEST-000: {r.status_code} {r.text}")
|
|
# Fallback: try first available vehicle
|
|
r2 = await client.get("/api/v1/assets/vehicles", headers=headers)
|
|
if r2.status_code == 200:
|
|
vehicles = r2.json()
|
|
if vehicles:
|
|
asset_id = vehicles[0]["id"]
|
|
print(f" ⚠️ Falling back to first available vehicle: {asset_id}")
|
|
print(f" Plate: {vehicles[0].get('license_plate')}")
|
|
else:
|
|
vehicle = r.json()
|
|
print(f" Plate: {vehicle.get('license_plate')} - {vehicle.get('brand')} {vehicle.get('model')}")
|
|
|
|
# Run all direction tests
|
|
results = []
|
|
|
|
results.append(await test_direction_1_event_to_cost(token, asset_id))
|
|
results.append(await test_direction_2_cost_to_event(token, asset_id))
|
|
results.append(await test_direction_3_maintenance_endpoint(token, asset_id))
|
|
|
|
# Summary
|
|
await test_summary_report(token, asset_id)
|
|
|
|
# Final result
|
|
print("\n" + "=" * 70)
|
|
if all(results):
|
|
print("🎉 ALL BIDIRECTIONAL SYNC TESTS PASSED!")
|
|
else:
|
|
print(f"❌ SOME TESTS FAILED: {sum(1 for r in results if not r)}/{len(results)} failed")
|
|
print("=" * 70)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|