830 lines
34 KiB
Python
830 lines
34 KiB
Python
|
|
"""Tests for contact CRUD, search, filter, and contact person endpoints."""
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
|
||
|
|
from app.database import Base, get_db
|
||
|
|
from app.main import app
|
||
|
|
from app.models.contact import Contact, ContactPerson
|
||
|
|
from app.utils.ust_validation import validate_vat_id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def sample_contact_data():
|
||
|
|
"""Valid contact data for creation."""
|
||
|
|
return {
|
||
|
|
"company_name": "Müller Transport GmbH",
|
||
|
|
"legal_form": "GmbH",
|
||
|
|
"address_street": "Hauptstraße 1",
|
||
|
|
"address_zip": "10115",
|
||
|
|
"address_city": "Berlin",
|
||
|
|
"address_country": "DE",
|
||
|
|
"vat_id": "DE123456789",
|
||
|
|
"phone": "+49 30 12345678",
|
||
|
|
"email": "info@mueller-transport.de",
|
||
|
|
"website": "https://mueller-transport.de",
|
||
|
|
"role": "kaeufer",
|
||
|
|
"is_private": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def sample_eu_contact_data():
|
||
|
|
"""Valid EU contact data (non-DE)."""
|
||
|
|
return {
|
||
|
|
"company_name": "Van der Berg Logistics B.V.",
|
||
|
|
"address_street": "Keizersgracht 100",
|
||
|
|
"address_zip": "1015",
|
||
|
|
"address_city": "Amsterdam",
|
||
|
|
"address_country": "NL",
|
||
|
|
"vat_id": "NL123456789B01",
|
||
|
|
"email": "info@vandberg.nl",
|
||
|
|
"role": "verkaeufer",
|
||
|
|
"is_private": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def sample_beide_contact_data():
|
||
|
|
"""Valid contact with role 'beide'."""
|
||
|
|
return {
|
||
|
|
"company_name": "Schmidt & Söhne KG",
|
||
|
|
"address_city": "Hamburg",
|
||
|
|
"address_country": "DE",
|
||
|
|
"role": "beide",
|
||
|
|
"is_private": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def created_contact(admin_client, sample_contact_data):
|
||
|
|
"""Create a contact via API and return the response."""
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert response.status_code == 201, response.text
|
||
|
|
return response.json()
|
||
|
|
|
||
|
|
|
||
|
|
class TestUstValidation:
|
||
|
|
"""Unit tests for USt-IdNr. validation utility."""
|
||
|
|
|
||
|
|
def test_validate_de_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("DE123456789") is True
|
||
|
|
|
||
|
|
def test_validate_de_vat_id_invalid_short(self):
|
||
|
|
assert validate_vat_id("DE12345678") is False
|
||
|
|
|
||
|
|
def test_validate_de_vat_id_invalid_long(self):
|
||
|
|
assert validate_vat_id("DE1234567890") is False
|
||
|
|
|
||
|
|
def test_validate_at_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("ATU12345678") is True
|
||
|
|
|
||
|
|
def test_validate_nl_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("NL123456789B01") is True
|
||
|
|
|
||
|
|
def test_validate_fr_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("FRAB123456789") is True
|
||
|
|
|
||
|
|
def test_validate_it_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("IT12345678901") is True
|
||
|
|
|
||
|
|
def test_validate_es_vat_id_valid(self):
|
||
|
|
assert validate_vat_id("ESA1234567B") is True
|
||
|
|
|
||
|
|
def test_validate_empty_vat_id(self):
|
||
|
|
assert validate_vat_id("") is True
|
||
|
|
|
||
|
|
def test_validate_none_vat_id(self):
|
||
|
|
assert validate_vat_id(None) is True
|
||
|
|
|
||
|
|
def test_validate_non_eu_country(self):
|
||
|
|
assert validate_vat_id("US123456789") is False
|
||
|
|
|
||
|
|
def test_validate_lowercase_normalised(self):
|
||
|
|
assert validate_vat_id("de123456789") is True
|
||
|
|
|
||
|
|
def test_validate_with_spaces(self):
|
||
|
|
assert validate_vat_id("DE 123 456 789") is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactList:
|
||
|
|
"""GET /api/v1/contacts tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_returns_200_with_pagination(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts returns 200 with paginated list."""
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=20")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert "items" in data
|
||
|
|
assert "total" in data
|
||
|
|
assert "page" in data
|
||
|
|
assert "page_size" in data
|
||
|
|
assert data["total"] >= 1
|
||
|
|
assert len(data["items"]) >= 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_filter_by_role_kaeufer_includes_beide(
|
||
|
|
self, admin_client, sample_contact_data, sample_beide_contact_data
|
||
|
|
):
|
||
|
|
"""GET /api/v1/contacts?role=kaeufer returns kaeufer + beide contacts."""
|
||
|
|
# Create a kaeufer contact
|
||
|
|
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert resp1.status_code == 201
|
||
|
|
# Create a beide contact
|
||
|
|
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data)
|
||
|
|
assert resp2.status_code == 201
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?role=kaeufer")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
roles = [c["role"] for c in data["items"]]
|
||
|
|
assert "kaeufer" in roles
|
||
|
|
assert "beide" in roles
|
||
|
|
assert "verkaeufer" not in roles
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_filter_by_role_verkaeufer_includes_beide(
|
||
|
|
self, admin_client, sample_eu_contact_data, sample_beide_contact_data
|
||
|
|
):
|
||
|
|
"""GET /api/v1/contacts?role=verkaeufer returns verkaeufer + beide contacts."""
|
||
|
|
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||
|
|
assert resp1.status_code == 201
|
||
|
|
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data)
|
||
|
|
assert resp2.status_code == 201
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?role=verkaeufer")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
roles = [c["role"] for c in data["items"]]
|
||
|
|
assert "verkaeufer" in roles
|
||
|
|
assert "beide" in roles
|
||
|
|
assert "kaeufer" not in roles
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_filter_is_eu_true(
|
||
|
|
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||
|
|
):
|
||
|
|
"""GET /api/v1/contacts?is_eu=true returns only non-DE contacts."""
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?is_eu=true")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
for item in data["items"]:
|
||
|
|
assert item["address_country"] != "DE"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_filter_is_eu_false(
|
||
|
|
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||
|
|
):
|
||
|
|
"""GET /api/v1/contacts?is_eu=false returns only DE contacts."""
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?is_eu=false")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
for item in data["items"]:
|
||
|
|
assert item["address_country"] == "DE"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_search_by_company_name(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts?search=mueller returns matching contacts."""
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?search=mueller")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["total"] >= 1
|
||
|
|
assert any("Müller" in c["company_name"] for c in data["items"])
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_search_by_city(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts?search=berlin returns matching contacts."""
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?search=berlin")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["total"] >= 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_search_no_results(self, admin_client):
|
||
|
|
"""GET /api/v1/contacts?search=nonexistent returns empty list."""
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?search=nonexistent_xyz")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["total"] == 0
|
||
|
|
assert len(data["items"]) == 0
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_sort_by_company_name(self, admin_client, sample_contact_data, sample_eu_contact_data):
|
||
|
|
"""GET /api/v1/contacts?sort=company_name returns sorted list."""
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?sort=company_name")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
names = [c["company_name"] for c in data["items"]]
|
||
|
|
assert names == sorted(names)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_pagination(self, admin_client, sample_contact_data):
|
||
|
|
"""GET /api/v1/contacts?page=1&page_size=1 returns correct pagination."""
|
||
|
|
for i in range(3):
|
||
|
|
data = {**sample_contact_data, "company_name": f"Company {i} GmbH"}
|
||
|
|
await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
|
||
|
|
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=1")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["page"] == 1
|
||
|
|
assert data["page_size"] == 1
|
||
|
|
assert len(data["items"]) == 1
|
||
|
|
assert data["total"] >= 3
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactDetail:
|
||
|
|
"""GET /api/v1/contacts/:id tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_contact_returns_200_with_detail(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts/:id returns 200 with contact detail."""
|
||
|
|
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["id"] == created_contact["id"]
|
||
|
|
assert data["company_name"] == "Müller Transport GmbH"
|
||
|
|
assert "contact_persons" in data
|
||
|
|
assert isinstance(data["contact_persons"], list)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_contact_nonexistent_returns_404(self, admin_client):
|
||
|
|
"""GET /api/v1/contacts/:nonexistent returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await admin_client.get(f"/api/v1/contacts/{fake_id}")
|
||
|
|
assert response.status_code == 404
|
||
|
|
data = response.json()
|
||
|
|
assert data["detail"]["error"]["code"] == "CONTACT_NOT_FOUND"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_contact_after_soft_delete_returns_404(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts/:id after soft-delete returns 404."""
|
||
|
|
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert del_resp.status_code == 200
|
||
|
|
get_resp = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert get_resp.status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactCreate:
|
||
|
|
"""POST /api/v1/contacts tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_returns_201(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts with valid data returns 201."""
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert response.status_code == 201
|
||
|
|
data = response.json()
|
||
|
|
assert data["company_name"] == sample_contact_data["company_name"]
|
||
|
|
assert data["role"] == "kaeufer"
|
||
|
|
assert data["vat_id_status"] == "ungeprueft"
|
||
|
|
assert data["id"] is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_with_invalid_vat_id_returns_422(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts with invalid VAT ID format returns 422."""
|
||
|
|
data = {**sample_contact_data, "vat_id": "INVALID123"}
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_with_de_vat_too_short_returns_422(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts with too-short DE VAT ID returns 422."""
|
||
|
|
data = {**sample_contact_data, "vat_id": "DE12345678"}
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_with_no_vat_id_returns_201(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts without VAT ID returns 201."""
|
||
|
|
data = {**sample_contact_data}
|
||
|
|
data.pop("vat_id")
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
assert response.status_code == 201
|
||
|
|
assert response.json()["vat_id"] is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_with_contact_persons(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts with nested contact persons returns 201."""
|
||
|
|
data = {
|
||
|
|
**sample_contact_data,
|
||
|
|
"contact_persons": [
|
||
|
|
{
|
||
|
|
"name": "Hans Müller",
|
||
|
|
"function": "Geschäftsführer",
|
||
|
|
"phone": "+49 30 87654321",
|
||
|
|
"email": "hans@mueller-transport.de",
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
assert response.status_code == 201
|
||
|
|
contact_data = response.json()
|
||
|
|
assert len(contact_data["contact_persons"]) == 1
|
||
|
|
assert contact_data["contact_persons"][0]["name"] == "Hans Müller"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_missing_required_fields_returns_422(self, admin_client):
|
||
|
|
"""POST /api/v1/contacts with missing required fields returns 422."""
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json={"address_country": "DE"})
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_invalid_role_returns_422(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts with invalid role returns 422."""
|
||
|
|
data = {**sample_contact_data, "role": "invalid_role"}
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactUpdate:
|
||
|
|
"""PUT /api/v1/contacts/:id tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_contact_returns_200(self, admin_client, created_contact):
|
||
|
|
"""PUT /api/v1/contacts/:id with valid data returns 200."""
|
||
|
|
response = await admin_client.put(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}",
|
||
|
|
json={"company_name": "Müller Transport AG", "phone": "+49 30 99999999"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["company_name"] == "Müller Transport AG"
|
||
|
|
assert data["phone"] == "+49 30 99999999"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_contact_nonexistent_returns_404(self, admin_client):
|
||
|
|
"""PUT /api/v1/contacts/:nonexistent returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await admin_client.put(
|
||
|
|
f"/api/v1/contacts/{fake_id}",
|
||
|
|
json={"company_name": "Test GmbH"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_contact_invalid_vat_id_returns_422(self, admin_client, created_contact):
|
||
|
|
"""PUT /api/v1/contacts/:id with invalid VAT ID returns 422."""
|
||
|
|
response = await admin_client.put(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}",
|
||
|
|
json={"vat_id": "INVALID123"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 422
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_contact_no_fields_returns_400(self, admin_client, created_contact):
|
||
|
|
"""PUT /api/v1/contacts/:id with no fields returns 400."""
|
||
|
|
response = await admin_client.put(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}",
|
||
|
|
json={},
|
||
|
|
)
|
||
|
|
assert response.status_code == 400
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactDelete:
|
||
|
|
"""DELETE /api/v1/contacts/:id tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_contact_returns_200(self, admin_client, created_contact):
|
||
|
|
"""DELETE /api/v1/contacts/:id returns 200 (soft delete)."""
|
||
|
|
response = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert data["deleted_at"] is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_contact_nonexistent_returns_404(self, admin_client):
|
||
|
|
"""DELETE /api/v1/contacts/:nonexistent returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await admin_client.delete(f"/api/v1/contacts/{fake_id}")
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_deleted_contact_not_in_list(self, admin_client, created_contact):
|
||
|
|
"""Soft-deleted contact does not appear in list."""
|
||
|
|
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert del_resp.status_code == 200
|
||
|
|
list_resp = await admin_client.get("/api/v1/contacts/")
|
||
|
|
assert list_resp.status_code == 200
|
||
|
|
ids = [c["id"] for c in list_resp.json()["items"]]
|
||
|
|
assert created_contact["id"] not in ids
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactPersons:
|
||
|
|
"""Contact person management endpoints."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_contact_person_returns_201(self, admin_client, created_contact):
|
||
|
|
"""POST /api/v1/contacts/:id/persons returns 201."""
|
||
|
|
response = await admin_client.post(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||
|
|
json={
|
||
|
|
"name": "Anna Schmidt",
|
||
|
|
"function": "Einkauf",
|
||
|
|
"phone": "+49 30 11122233",
|
||
|
|
"email": "anna@mueller-transport.de",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert response.status_code == 201
|
||
|
|
data = response.json()
|
||
|
|
assert data["name"] == "Anna Schmidt"
|
||
|
|
assert data["function"] == "Einkauf"
|
||
|
|
assert data["id"] is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_contact_person_to_nonexistent_contact_returns_404(self, admin_client):
|
||
|
|
"""POST /api/v1/contacts/:nonexistent/persons returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await admin_client.post(
|
||
|
|
f"/api/v1/contacts/{fake_id}/persons",
|
||
|
|
json={"name": "Test Person"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_contact_person_returns_204(self, admin_client, created_contact):
|
||
|
|
"""DELETE /api/v1/contacts/:id/persons/:person_id returns 204."""
|
||
|
|
# First add a person
|
||
|
|
add_resp = await admin_client.post(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||
|
|
json={"name": "Test Person"},
|
||
|
|
)
|
||
|
|
assert add_resp.status_code == 201
|
||
|
|
person_id = add_resp.json()["id"]
|
||
|
|
|
||
|
|
# Then remove it
|
||
|
|
del_resp = await admin_client.delete(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}/persons/{person_id}"
|
||
|
|
)
|
||
|
|
assert del_resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_nonexistent_contact_person_returns_404(self, admin_client, created_contact):
|
||
|
|
"""DELETE /api/v1/contacts/:id/persons/:nonexistent returns 404."""
|
||
|
|
fake_person_id = uuid.uuid4()
|
||
|
|
response = await admin_client.delete(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}/persons/{fake_person_id}"
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_contact_detail_includes_persons(self, admin_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts/:id includes contact persons in response."""
|
||
|
|
# Add a person
|
||
|
|
await admin_client.post(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||
|
|
json={"name": "Max Mustermann", "function": "Vertrieb"},
|
||
|
|
)
|
||
|
|
# Get contact detail
|
||
|
|
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()
|
||
|
|
assert len(data["contact_persons"]) >= 1
|
||
|
|
assert data["contact_persons"][0]["name"] == "Max Mustermann"
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactRBAC:
|
||
|
|
"""RBAC enforcement tests."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_requires_auth(self, client):
|
||
|
|
"""GET /api/v1/contacts without auth returns 401."""
|
||
|
|
response = await client.get("/api/v1/contacts/")
|
||
|
|
assert response.status_code == 401
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_as_admin_returns_201(self, admin_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts as admin returns 201."""
|
||
|
|
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert response.status_code == 201
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_contact_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts as verkaeufer returns 201."""
|
||
|
|
response = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert response.status_code == 201
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_contacts_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact):
|
||
|
|
"""GET /api/v1/contacts as verkaeufer returns 200 (read allowed)."""
|
||
|
|
response = await verkaeufer_client.get("/api/v1/contacts/")
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_contact_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact):
|
||
|
|
"""PUT /api/v1/contacts/:id as verkaeufer returns 200."""
|
||
|
|
response = await verkaeufer_client.put(
|
||
|
|
f"/api/v1/contacts/{created_contact['id']}",
|
||
|
|
json={"company_name": "Updated by Verkaeufer"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_contact_as_verkaeufer_returns_200(self, verkaeufer_client, sample_contact_data):
|
||
|
|
"""DELETE /api/v1/contacts/:id as verkaeufer returns 200."""
|
||
|
|
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
contact_id = resp.json()["id"]
|
||
|
|
del_resp = await verkaeufer_client.delete(f"/api/v1/contacts/{contact_id}")
|
||
|
|
assert del_resp.status_code == 200
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_person_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data):
|
||
|
|
"""POST /api/v1/contacts/:id/persons as verkaeufer returns 201."""
|
||
|
|
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
assert resp.status_code == 201
|
||
|
|
contact_id = resp.json()["id"]
|
||
|
|
person_resp = await verkaeufer_client.post(
|
||
|
|
f"/api/v1/contacts/{contact_id}/persons",
|
||
|
|
json={"name": "Test Person"},
|
||
|
|
)
|
||
|
|
assert person_resp.status_code == 201
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_person_as_verkaeufer_returns_204(self, verkaeufer_client, sample_contact_data):
|
||
|
|
"""DELETE /api/v1/contacts/:id/persons/:pid as verkaeufer returns 204."""
|
||
|
|
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
contact_id = resp.json()["id"]
|
||
|
|
person_resp = await verkaeufer_client.post(
|
||
|
|
f"/api/v1/contacts/{contact_id}/persons",
|
||
|
|
json={"name": "To Remove"},
|
||
|
|
)
|
||
|
|
person_id = person_resp.json()["id"]
|
||
|
|
del_resp = await verkaeufer_client.delete(
|
||
|
|
f"/api/v1/contacts/{contact_id}/persons/{person_id}"
|
||
|
|
)
|
||
|
|
assert del_resp.status_code == 204
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_update_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||
|
|
"""PUT /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await verkaeufer_client.put(
|
||
|
|
f"/api/v1/contacts/{fake_id}",
|
||
|
|
json={"company_name": "Test"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_delete_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||
|
|
"""DELETE /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await verkaeufer_client.delete(f"/api/v1/contacts/{fake_id}")
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_add_person_to_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||
|
|
"""POST /api/v1/contacts/:nonexistent/persons as verkaeufer returns 404."""
|
||
|
|
fake_id = uuid.uuid4()
|
||
|
|
response = await verkaeufer_client.post(
|
||
|
|
f"/api/v1/contacts/{fake_id}/persons",
|
||
|
|
json={"name": "Test"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_nonexistent_person_returns_404_as_verkaeufer(self, verkaeufer_client, sample_contact_data):
|
||
|
|
"""DELETE /api/v1/contacts/:id/persons/:nonexistent as verkaeufer returns 404."""
|
||
|
|
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||
|
|
contact_id = resp.json()["id"]
|
||
|
|
fake_person_id = uuid.uuid4()
|
||
|
|
response = await verkaeufer_client.delete(
|
||
|
|
f"/api/v1/contacts/{contact_id}/persons/{fake_person_id}"
|
||
|
|
)
|
||
|
|
assert response.status_code == 404
|
||
|
|
|
||
|
|
|
||
|
|
class TestContactServiceDirect:
|
||
|
|
"""Direct service-level tests for coverage."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_list_contacts_with_all_filters(self, db_session):
|
||
|
|
"""Test list_contacts with all filter parameters."""
|
||
|
|
from app.services import contact_service
|
||
|
|
contact1 = Contact(
|
||
|
|
company_name="Alpha GmbH",
|
||
|
|
address_city="Berlin",
|
||
|
|
address_country="DE",
|
||
|
|
role="kaeufer",
|
||
|
|
)
|
||
|
|
contact2 = Contact(
|
||
|
|
company_name="Beta B.V.",
|
||
|
|
address_city="Amsterdam",
|
||
|
|
address_country="NL",
|
||
|
|
role="verkaeufer",
|
||
|
|
)
|
||
|
|
contact3 = Contact(
|
||
|
|
company_name="Gamma KG",
|
||
|
|
address_city="Hamburg",
|
||
|
|
address_country="DE",
|
||
|
|
role="beide",
|
||
|
|
)
|
||
|
|
db_session.add_all([contact1, contact2, contact3])
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
# Test search
|
||
|
|
results, total = await contact_service.list_contacts(db_session, search="alpha")
|
||
|
|
assert total == 1
|
||
|
|
assert results[0].company_name == "Alpha GmbH"
|
||
|
|
|
||
|
|
# Test role filter (kaeufer includes beide)
|
||
|
|
results, total = await contact_service.list_contacts(db_session, role="kaeufer")
|
||
|
|
assert total == 2
|
||
|
|
|
||
|
|
# Test is_eu filter
|
||
|
|
results, total = await contact_service.list_contacts(db_session, is_eu=True)
|
||
|
|
assert total == 1
|
||
|
|
assert results[0].address_country == "NL"
|
||
|
|
|
||
|
|
# Test is_eu=false (Inland)
|
||
|
|
results, total = await contact_service.list_contacts(db_session, is_eu=False)
|
||
|
|
assert total == 2
|
||
|
|
|
||
|
|
# Test is_private filter
|
||
|
|
contact4 = Contact(
|
||
|
|
company_name="Private Person",
|
||
|
|
address_country="DE",
|
||
|
|
role="kaeufer",
|
||
|
|
is_private=True,
|
||
|
|
)
|
||
|
|
db_session.add(contact4)
|
||
|
|
await db_session.commit()
|
||
|
|
results, total = await contact_service.list_contacts(db_session, is_private=True)
|
||
|
|
assert total == 1
|
||
|
|
|
||
|
|
# Test sort descending
|
||
|
|
results, total = await contact_service.list_contacts(db_session, sort="-company_name")
|
||
|
|
names = [r.company_name for r in results]
|
||
|
|
assert names == sorted(names, reverse=True)
|
||
|
|
|
||
|
|
# Test invalid sort field falls back to created_at
|
||
|
|
results, total = await contact_service.list_contacts(db_session, sort="invalid_field")
|
||
|
|
assert total >= 4
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_get_contact_by_id_not_found(self, db_session):
|
||
|
|
"""Test get_contact_by_id returns None for nonexistent ID."""
|
||
|
|
from app.services import contact_service
|
||
|
|
result = await contact_service.get_contact_by_id(db_session, uuid.uuid4())
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_create_contact_with_persons(self, db_session):
|
||
|
|
"""Test create_contact with nested contact persons."""
|
||
|
|
from app.services import contact_service
|
||
|
|
data = {
|
||
|
|
"company_name": "Test Service GmbH",
|
||
|
|
"address_country": "DE",
|
||
|
|
"role": "kaeufer",
|
||
|
|
"contact_persons": [
|
||
|
|
{"name": "Person 1", "function": "CEO"},
|
||
|
|
{"name": "Person 2", "phone": "+49 30 123"},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
contact = await contact_service.create_contact(db_session, data)
|
||
|
|
assert contact.company_name == "Test Service GmbH"
|
||
|
|
assert len(contact.contact_persons) == 2
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_update_contact_not_found(self, db_session):
|
||
|
|
"""Test update_contact returns None for nonexistent ID."""
|
||
|
|
from app.services import contact_service
|
||
|
|
result = await contact_service.update_contact(db_session, uuid.uuid4(), {"company_name": "Test"})
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_soft_delete_contact_not_found(self, db_session):
|
||
|
|
"""Test soft_delete_contact returns None for nonexistent ID."""
|
||
|
|
from app.services import contact_service
|
||
|
|
result = await contact_service.soft_delete_contact(db_session, uuid.uuid4())
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_add_contact_person_not_found(self, db_session):
|
||
|
|
"""Test add_contact_person returns None for nonexistent contact."""
|
||
|
|
from app.services import contact_service
|
||
|
|
result = await contact_service.add_contact_person(db_session, uuid.uuid4(), {"name": "Test"})
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_remove_contact_person_not_found(self, db_session):
|
||
|
|
"""Test remove_contact_person returns False for nonexistent person."""
|
||
|
|
from app.services import contact_service
|
||
|
|
result = await contact_service.remove_contact_person(db_session, uuid.uuid4(), uuid.uuid4())
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_update_contact_success(self, db_session):
|
||
|
|
"""Test update_contact successfully updates fields."""
|
||
|
|
from app.services import contact_service
|
||
|
|
contact = Contact(
|
||
|
|
company_name="Original GmbH",
|
||
|
|
address_country="DE",
|
||
|
|
role="kaeufer",
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(contact)
|
||
|
|
|
||
|
|
updated = await contact_service.update_contact(db_session, contact.id, {"company_name": "Updated GmbH", "phone": "+49 30 999"})
|
||
|
|
assert updated.company_name == "Updated GmbH"
|
||
|
|
assert updated.phone == "+49 30 999"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_soft_delete_contact_success(self, db_session):
|
||
|
|
"""Test soft_delete_contact sets deleted_at."""
|
||
|
|
from app.services import contact_service
|
||
|
|
contact = Contact(
|
||
|
|
company_name="To Delete GmbH",
|
||
|
|
address_country="DE",
|
||
|
|
role="kaeufer",
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(contact)
|
||
|
|
|
||
|
|
deleted = await contact_service.soft_delete_contact(db_session, contact.id)
|
||
|
|
assert deleted.deleted_at is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_service_add_and_remove_contact_person(self, db_session):
|
||
|
|
"""Test add_contact_person and remove_contact_person."""
|
||
|
|
from app.services import contact_service
|
||
|
|
contact = Contact(
|
||
|
|
company_name="Person Test GmbH",
|
||
|
|
address_country="DE",
|
||
|
|
role="kaeufer",
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(contact)
|
||
|
|
|
||
|
|
person = await contact_service.add_contact_person(db_session, contact.id, {"name": "Test Person", "function": "Manager"})
|
||
|
|
assert person is not None
|
||
|
|
assert person.name == "Test Person"
|
||
|
|
|
||
|
|
removed = await contact_service.remove_contact_person(db_session, contact.id, person.id)
|
||
|
|
assert removed is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_or_raise_valid(self):
|
||
|
|
"""Test validate_vat_id_or_raise with valid VAT ID."""
|
||
|
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||
|
|
result = validate_vat_id_or_raise("DE123456789")
|
||
|
|
assert result == "DE123456789"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_or_raise_none(self):
|
||
|
|
"""Test validate_vat_id_or_raise with None."""
|
||
|
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||
|
|
result = validate_vat_id_or_raise(None)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_or_raise_empty(self):
|
||
|
|
"""Test validate_vat_id_or_raise with empty string."""
|
||
|
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||
|
|
result = validate_vat_id_or_raise("")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_or_raise_invalid(self):
|
||
|
|
"""Test validate_vat_id_or_raise raises ValueError for invalid format."""
|
||
|
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||
|
|
with pytest.raises(ValueError, match="Invalid VAT ID format"):
|
||
|
|
validate_vat_id_or_raise("DE123")
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_get_country_code(self):
|
||
|
|
"""Test get_country_code_from_vat_id."""
|
||
|
|
from app.utils.ust_validation import get_country_code_from_vat_id
|
||
|
|
assert get_country_code_from_vat_id("DE123456789") == "DE"
|
||
|
|
assert get_country_code_from_vat_id("at123") == "AT"
|
||
|
|
assert get_country_code_from_vat_id("") is None
|
||
|
|
assert get_country_code_from_vat_id("A") is None
|
||
|
|
assert get_country_code_from_vat_id("12") is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_ust_validation_eu_fallback(self):
|
||
|
|
"""Test EU fallback pattern for countries without specific regex."""
|
||
|
|
from app.utils.ust_validation import validate_vat_id
|
||
|
|
# Ireland (IE) is in EU set but has no specific pattern
|
||
|
|
assert validate_vat_id("IE1234567AB") is True
|
||
|
|
# Bulgaria (BG) is in EU set but has no specific pattern
|
||
|
|
assert validate_vat_id("BG1234567890") is True
|
||
|
|
# Too short for fallback
|
||
|
|
assert validate_vat_id("BG123") is False
|