"""Tests for sales module: CRUD, contract PDF, GwG logic, vehicle status changes.""" import uuid from datetime import date from decimal import Decimal from unittest.mock import patch, MagicMock import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.models.sale import Sale from app.models.vehicle import Vehicle from app.models.contact import Contact from app.utils.contract_pdf import build_contract_html from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS @pytest_asyncio.fixture async def test_vehicle(db_session: AsyncSession) -> Vehicle: """Create a test vehicle.""" vehicle = Vehicle( make="Mercedes", model="Actros", fin="WDB96312345678901", year=2020, vehicle_type="lkw", price=Decimal("45000.00"), availability="available", condition="used", ) db_session.add(vehicle) await db_session.commit() await db_session.refresh(vehicle) return vehicle @pytest_asyncio.fixture async def test_buyer(db_session: AsyncSession) -> Contact: """Create a test buyer contact.""" contact = Contact( company_name="Test Buyer GmbH", role="kaeufer", address_country="DE", vat_id="DE123456789", address_street="Teststr. 1", address_zip="12345", address_city="Berlin", email="buyer@test.com", ) db_session.add(contact) await db_session.commit() await db_session.refresh(contact) return contact @pytest_asyncio.fixture async def test_seller(db_session: AsyncSession) -> Contact: """Create a test seller contact.""" contact = Contact( company_name="Test Seller GmbH", role="verkaeufer", address_country="DE", vat_id="DE987654321", address_street="Sellerstr. 2", address_zip="54321", address_city="München", email="seller@test.com", ) db_session.add(contact) await db_session.commit() await db_session.refresh(contact) return contact @pytest_asyncio.fixture async def test_sale(db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact) -> Sale: """Create a test sale.""" sale = Sale( vehicle_id=test_vehicle.id, buyer_contact_id=test_buyer.id, sale_price=Decimal("45000.00"), sale_date=date(2025, 1, 15), status="completed", is_gwg=False, ) db_session.add(sale) await db_session.commit() await db_session.refresh(sale) return sale class TestSaleCRUD: """Test sale CRUD operations via API.""" @pytest_asyncio.fixture async def setup_data(self, db_session, test_vehicle, test_buyer): """Ensure vehicle and buyer exist.""" return test_vehicle, test_buyer async def test_list_sales_empty(self, admin_client: AsyncClient): """GET /sales with no sales returns empty list.""" response = await admin_client.get("/api/v1/sales/") assert response.status_code == 200 data = response.json() assert data["items"] == [] assert data["total"] == 0 assert data["page"] == 1 assert data["page_size"] == 20 async def test_create_sale(self, admin_client: AsyncClient, test_vehicle, test_buyer): """POST /sales with valid data returns 201.""" response = await admin_client.post("/api/v1/sales/", json={ "vehicle_id": str(test_vehicle.id), "buyer_contact_id": str(test_buyer.id), "sale_price": "45000.00", "sale_date": "2025-01-15", "status": "draft", "is_gwg": False, }) assert response.status_code == 201 data = response.json() assert data["vehicle_id"] == str(test_vehicle.id) assert data["buyer_contact_id"] == str(test_buyer.id) assert data["sale_price"] == "45000.00" assert data["status"] == "draft" assert data["is_gwg"] is False async def test_create_sale_without_vehicle_id(self, admin_client: AsyncClient, test_buyer): """POST /sales without vehicle_id returns 422.""" response = await admin_client.post("/api/v1/sales/", json={ "buyer_contact_id": str(test_buyer.id), "sale_price": "45000.00", }) assert response.status_code == 422 async def test_create_sale_without_buyer_contact_id(self, admin_client: AsyncClient, test_vehicle): """POST /sales without buyer_contact_id returns 422.""" response = await admin_client.post("/api/v1/sales/", json={ "vehicle_id": str(test_vehicle.id), "sale_price": "45000.00", }) assert response.status_code == 422 async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale): """GET /sales/:id returns sale detail with nested vehicle and contact.""" response = await admin_client.get(f"/api/v1/sales/{test_sale.id}") assert response.status_code == 200 data = response.json() assert data["id"] == str(test_sale.id) assert data["vehicle"]["make"] == "Mercedes" assert data["buyer"]["company_name"] == "Test Buyer GmbH" async def test_get_sale_not_found(self, admin_client: AsyncClient): """GET /sales/:nonexistent returns 404.""" response = await admin_client.get(f"/api/v1/sales/{uuid.uuid4()}") assert response.status_code == 404 async def test_update_sale(self, admin_client: AsyncClient, test_sale): """PUT /sales/:id updates sale fields.""" response = await admin_client.put(f"/api/v1/sales/{test_sale.id}", json={ "sale_price": "42000.00", "status": "completed", }) assert response.status_code == 200 data = response.json() assert data["sale_price"] == "42000.00" assert data["status"] == "completed" async def test_delete_sale_cancels_and_restores_vehicle(self, admin_client: AsyncClient, test_sale, db_session): """DELETE /sales/:id cancels sale and restores vehicle status to 'available'.""" # First set vehicle to sold (as create_sale would) vehicle = await db_session.get(Vehicle, test_sale.vehicle_id) vehicle.availability = "sold" await db_session.commit() response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}") assert response.status_code == 200 data = response.json() assert data["status"] == "cancelled" # Verify vehicle status restored await db_session.refresh(vehicle) assert vehicle.availability == "available" async def test_list_sales_with_filter(self, admin_client: AsyncClient, test_sale): """GET /sales?status=completed filters by status.""" response = await admin_client.get("/api/v1/sales/?status=completed") assert response.status_code == 200 data = response.json() assert data["total"] >= 1 for item in data["items"]: assert item["status"] == "completed" async def test_list_sales_with_date_filter(self, admin_client: AsyncClient, test_sale): """GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range.""" response = await admin_client.get("/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31") assert response.status_code == 200 data = response.json() assert data["total"] >= 1 class TestSaleVehicleStatus: """Test vehicle status changes on sale create/delete.""" async def test_create_sale_sets_vehicle_sold(self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session): """Creating a sale sets vehicle availability to 'sold'.""" response = await admin_client.post("/api/v1/sales/", json={ "vehicle_id": str(test_vehicle.id), "buyer_contact_id": str(test_buyer.id), "sale_price": "45000.00", "sale_date": "2025-01-15", "status": "draft", }) assert response.status_code == 201 # Verify vehicle status await db_session.refresh(test_vehicle) assert test_vehicle.availability == "sold" async def test_cancel_sale_restores_vehicle_available(self, admin_client: AsyncClient, test_sale, db_session): """Cancelling a sale restores vehicle availability to 'available'.""" # Set vehicle to sold first vehicle = await db_session.get(Vehicle, test_sale.vehicle_id) vehicle.availability = "sold" await db_session.commit() response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}") assert response.status_code == 200 await db_session.refresh(vehicle) assert vehicle.availability == "available" class TestContractPDF: """Test contract PDF generation and GwG logic.""" async def test_regenerate_contract(self, admin_client: AsyncClient, test_sale): """POST /sales/:id/contract regenerates contract PDF.""" with patch("app.services.sale_service.generate_contract_pdf") as mock_pdf: mock_pdf.return_value = "/tmp/contracts/test_contract.pdf" response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/contract") assert response.status_code == 200 data = response.json() assert data["sale_id"] == str(test_sale.id) assert "contract_pdf_path" in data async def test_download_contract_not_found(self, admin_client: AsyncClient, test_sale): """GET /sales/:id/contract returns 404 if no PDF generated.""" response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract") assert response.status_code == 404 async def test_download_contract_pdf(self, admin_client: AsyncClient, test_sale, db_session): """GET /sales/:id/contract returns PDF content.""" # Create a fake PDF file import os os.makedirs("/tmp/contracts", exist_ok=True) pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf" with open(pdf_path, "wb") as f: f.write(b"%PDF-1.4 fake pdf content") test_sale.contract_pdf_path = pdf_path db_session.add(test_sale) await db_session.commit() response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract") assert response.status_code == 200 assert response.headers["content-type"] == "application/pdf" # Cleanup if os.path.exists(pdf_path): os.remove(pdf_path) def test_gwg_clause_in_contract_html(self, test_sale, test_vehicle, test_buyer): """GwG clause appears in contract HTML when is_gwg=true and price <= 800.""" test_sale.is_gwg = True test_sale.sale_price = Decimal("500.00") test_sale.vehicle = test_vehicle test_sale.buyer = test_buyer html = build_contract_html(test_sale) assert "Geringwertige Wirtschaftsgüter" in html assert "§ 6 Abs. 2 EStG" in html def test_gwg_clause_not_in_contract_when_price_too_high(self, test_sale, test_vehicle, test_buyer): """GwG clause does NOT appear when price > 800 even if is_gwg=true.""" test_sale.is_gwg = True test_sale.sale_price = Decimal("5000.00") test_sale.vehicle = test_vehicle test_sale.buyer = test_buyer html = build_contract_html(test_sale) assert "Geringwertige Wirtschaftsgüter" not in html def test_gwg_clause_not_in_contract_when_not_gwg(self, test_sale, test_vehicle, test_buyer): """GwG clause does NOT appear when is_gwg=false.""" test_sale.is_gwg = False test_sale.sale_price = Decimal("500.00") test_sale.vehicle = test_vehicle test_sale.buyer = test_buyer html = build_contract_html(test_sale) assert "Geringwertige Wirtschaftsgüter" not in html def test_contract_html_contains_ust_id_field(self, test_sale, test_vehicle, test_buyer): """Contract HTML contains USt-IdNr. field.""" test_sale.vehicle = test_vehicle test_sale.buyer = test_buyer html = build_contract_html(test_sale) assert "USt-IdNr" in html assert "DE123456789" in html class TestUstIdVerification: """Test USt-IdNr. verification endpoint.""" async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale): """POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled.""" response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/verify-ust-id") assert response.status_code == 200 data = response.json() assert data["verified"] is False assert data["message"] == "BZSt API not available"