feat(T04): contact management + USt-IdNr. validation + contact UI

- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete
- ContactPerson model: 1:N relation with CASCADE delete
- USt-IdNr. validation: DE + 10 EU countries
- Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate
- Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle
- 73 backend tests (91% coverage), 20 frontend tests
This commit is contained in:
2026-07-14 12:14:21 +02:00
parent 74b5e6afb8
commit 2cf433a01c
20 changed files with 3002 additions and 97 deletions
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, vehicles
from app.routers import auth, contacts, users, vehicles
@asynccontextmanager
@@ -41,6 +41,7 @@ api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
api_v1_router.include_router(vehicles.router)
api_v1_router.include_router(contacts.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+200
View File
@@ -0,0 +1,200 @@
"""SQLAlchemy models for contacts and contact persons."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
String,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ContactRole(str, enum.Enum):
kaeufer = "kaeufer"
verkaeufer = "verkaeufer"
beide = "beide"
class VatIdStatus(str, enum.Enum):
ungeprueft = "ungeprueft"
geprueft = "geprueft"
ungueltig = "ungueltig"
manuell_bestaetigt = "manuell_bestaetigt"
class Contact(Base):
"""Contact entity representing a company or private contact."""
__tablename__ = "contacts"
__table_args__ = (
CheckConstraint(
"role IN ('kaeufer', 'verkaeufer', 'beide')",
name="ck_contacts_role",
),
CheckConstraint(
"vat_id_status IN ('ungeprueft', 'geprueft', 'ungueltig', 'manuell_bestaetigt')",
name="ck_contacts_vat_id_status",
),
CheckConstraint(
"char_length(address_country) = 2",
name="ck_contacts_country_alpha2",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_name: Mapped[str] = mapped_column(
String(255), nullable=False, index=True,
)
legal_form: Mapped[str | None] = mapped_column(
String(50), nullable=True,
)
address_street: Mapped[str | None] = mapped_column(
String(255), nullable=True,
)
address_zip: Mapped[str | None] = mapped_column(
String(10), nullable=True,
)
address_city: Mapped[str | None] = mapped_column(
String(100), nullable=True,
)
address_country: Mapped[str] = mapped_column(
String(2), nullable=False, default="DE", index=True,
)
vat_id: Mapped[str | None] = mapped_column(
String(20), nullable=True,
)
phone: Mapped[str | None] = mapped_column(
String(50), nullable=True,
)
email: Mapped[str | None] = mapped_column(
String(255), nullable=True,
)
website: Mapped[str | None] = mapped_column(
String(255), nullable=True,
)
role: Mapped[str] = mapped_column(
String(20), nullable=False, index=True,
)
vat_id_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="ungeprueft",
)
is_private: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True,
)
contact_persons: Mapped[list["ContactPerson"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Contact id={self.id} company_name={self.company_name}>"
def to_dict(self) -> dict:
"""Serialize contact for API responses."""
return {
"id": str(self.id),
"company_name": self.company_name,
"legal_form": self.legal_form,
"address_street": self.address_street,
"address_zip": self.address_zip,
"address_city": self.address_city,
"address_country": self.address_country,
"vat_id": self.vat_id,
"phone": self.phone,
"email": self.email,
"website": self.website,
"role": self.role,
"vat_id_status": self.vat_id_status,
"is_private": self.is_private,
"created_at": (
self.created_at.isoformat() if self.created_at else None
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
"deleted_at": (
self.deleted_at.isoformat() if self.deleted_at else None
),
"contact_persons": [
p.to_dict() for p in (self.contact_persons or [])
],
}
class ContactPerson(Base):
"""Contact person associated with a contact (company)."""
__tablename__ = "contact_persons"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
contact_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255), nullable=False,
)
function: Mapped[str | None] = mapped_column(
String(100), nullable=True,
)
phone: Mapped[str | None] = mapped_column(
String(50), nullable=True,
)
email: Mapped[str | None] = mapped_column(
String(255), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
def __repr__(self) -> str:
return f"<ContactPerson id={self.id} name={self.name}>"
def to_dict(self) -> dict:
"""Serialize contact person for API responses."""
return {
"id": str(self.id),
"contact_id": str(self.contact_id),
"name": self.name,
"function": self.function,
"phone": self.phone,
"email": self.email,
"created_at": (
self.created_at.isoformat() if self.created_at else None
),
}
+164
View File
@@ -0,0 +1,164 @@
"""Contacts router: CRUD, search, filter, and contact person management endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination, require_role
from app.models.user import User
from app.schemas.contact import (
ContactCreate,
ContactListResponse,
ContactPersonCreate,
ContactPersonResponse,
ContactResponse,
ContactUpdate,
)
from app.services import contact_service
router = APIRouter(prefix="/contacts", tags=["contacts"])
@router.get("/", response_model=ContactListResponse, status_code=status.HTTP_200_OK)
async def list_contacts(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
search: str | None = Query(None, description="Search in company_name, city, email, vat_id"),
role: str | None = Query(None, description="Filter by role (kaeufer, verkaeufer, beide)"),
is_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
is_private: bool | None = Query(None, description="Filter private contacts"),
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
current_user: User = Depends(get_current_user),
):
"""List contacts with pagination, filtering, and sorting.
All authenticated roles can read.
"""
contacts, total = await contact_service.list_contacts(
db,
page=pagination["page"],
page_size=pagination["page_size"],
search=search,
role=role,
is_eu=is_eu,
is_private=is_private,
sort=sort,
)
return ContactListResponse(
items=[ContactResponse.model_validate(c) for c in contacts],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
async def create_contact(
body: ContactCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
):
"""Create a new contact. Requires admin or verkaeufer role."""
data = body.model_dump(exclude_unset=False)
contact = await contact_service.create_contact(db, data)
return ContactResponse.model_validate(contact)
@router.get("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
async def get_contact(
contact_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single contact by ID with contact persons."""
contact = await contact_service.get_contact_by_id(db, contact_id)
if contact is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
)
return ContactResponse.model_validate(contact)
@router.put("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
async def update_contact(
contact_id: uuid.UUID,
body: ContactUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
):
"""Update a contact's fields. Requires admin or verkaeufer role."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
contact = await contact_service.update_contact(db, contact_id, updates)
if contact is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
)
return ContactResponse.model_validate(contact)
@router.delete("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
async def delete_contact(
contact_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
):
"""Soft-delete a contact (sets deleted_at). Requires admin or verkaeufer role."""
contact = await contact_service.soft_delete_contact(db, contact_id)
if contact is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
)
return ContactResponse.model_validate(contact)
@router.post(
"/{contact_id}/persons",
response_model=ContactPersonResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_contact_person(
contact_id: uuid.UUID,
body: ContactPersonCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
):
"""Add a contact person to an existing contact. Requires admin or verkaeufer role."""
person = await contact_service.add_contact_person(
db, contact_id, body.model_dump(exclude_unset=False)
)
if person is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
)
return ContactPersonResponse.model_validate(person)
@router.delete(
"/{contact_id}/persons/{person_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_contact_person(
contact_id: uuid.UUID,
person_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
):
"""Remove a contact person from a contact. Requires admin or verkaeufer role."""
deleted = await contact_service.remove_contact_person(db, contact_id, person_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "PERSON_NOT_FOUND", "message": "Contact person not found"}},
)
return None
+147
View File
@@ -0,0 +1,147 @@
"""Pydantic schemas for contact-related request and response bodies."""
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.utils.ust_validation import validate_vat_id
ContactRole = Literal["kaeufer", "verkaeufer", "beide"]
VatIdStatus = Literal["ungeprueft", "geprueft", "ungueltig", "manuell_bestaetigt"]
class ContactPersonBase(BaseModel):
"""Base contact person fields."""
name: str = Field(..., min_length=1, max_length=255)
function: Optional[str] = Field(None, max_length=100)
phone: Optional[str] = Field(None, max_length=50)
email: Optional[str] = Field(None, max_length=255)
class ContactPersonCreate(ContactPersonBase):
"""POST /api/v1/contacts/:id/persons request body."""
pass
class ContactPersonResponse(ContactPersonBase):
"""Contact person response schema."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
contact_id: uuid.UUID
created_at: Optional[datetime] = None
class ContactBase(BaseModel):
"""Base contact fields shared across schemas."""
company_name: str = Field(..., min_length=1, max_length=255)
legal_form: Optional[str] = Field(None, max_length=50)
address_street: Optional[str] = Field(None, max_length=255)
address_zip: Optional[str] = Field(None, max_length=10)
address_city: Optional[str] = Field(None, max_length=100)
address_country: str = Field("DE", min_length=2, max_length=2)
vat_id: Optional[str] = Field(None, max_length=20)
phone: Optional[str] = Field(None, max_length=50)
email: Optional[str] = Field(None, max_length=255)
website: Optional[str] = Field(None, max_length=255)
role: ContactRole
is_private: bool = False
@field_validator("vat_id")
@classmethod
def validate_vat_id_format(cls, v: str | None) -> str | None:
"""Validate VAT ID format if provided."""
if v is None or v == "":
return None
if not validate_vat_id(v):
raise ValueError(f"Invalid VAT ID format: '{v}'")
return v.strip().upper().replace(" ", "")
@field_validator("address_country")
@classmethod
def validate_country_code(cls, v: str) -> str:
"""Normalise country code to uppercase."""
return v.upper()
class ContactCreate(ContactBase):
"""POST /api/v1/contacts request body."""
contact_persons: list[ContactPersonCreate] = Field(
default_factory=list, description="Contact persons to create with the contact"
)
class ContactUpdate(BaseModel):
"""PUT /api/v1/contacts/:id request body (all fields optional)."""
company_name: Optional[str] = Field(None, min_length=1, max_length=255)
legal_form: Optional[str] = Field(None, max_length=50)
address_street: Optional[str] = Field(None, max_length=255)
address_zip: Optional[str] = Field(None, max_length=10)
address_city: Optional[str] = Field(None, max_length=100)
address_country: Optional[str] = Field(None, min_length=2, max_length=2)
vat_id: Optional[str] = Field(None, max_length=20)
phone: Optional[str] = Field(None, max_length=50)
email: Optional[str] = Field(None, max_length=255)
website: Optional[str] = Field(None, max_length=255)
role: Optional[ContactRole] = None
is_private: Optional[bool] = None
@field_validator("vat_id")
@classmethod
def validate_vat_id_format(cls, v: str | None) -> str | None:
"""Validate VAT ID format if provided."""
if v is None or v == "":
return None
if not validate_vat_id(v):
raise ValueError(f"Invalid VAT ID format: '{v}'")
return v.strip().upper().replace(" ", "")
@field_validator("address_country")
@classmethod
def validate_country_code(cls, v: str | None) -> str | None:
"""Normalise country code to uppercase."""
if v is not None:
return v.upper()
return v
class ContactResponse(BaseModel):
"""Contact response schema."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
company_name: str
legal_form: Optional[str] = None
address_street: Optional[str] = None
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_country: str
vat_id: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
role: str
vat_id_status: str = "ungeprueft"
is_private: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
class ContactListResponse(BaseModel):
"""Paginated contact list response."""
items: list[ContactResponse]
total: int
page: int
page_size: int
+263
View File
@@ -0,0 +1,263 @@
"""Contact service: CRUD, search, filter, pagination, and soft-delete operations."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.contact import Contact, ContactPerson
_SORTABLE_FIELDS: set[str] = {
"company_name",
"address_city",
"address_country",
"role",
"created_at",
"updated_at",
"email",
}
def _apply_filters(
stmt: select,
search: str | None = None,
role: str | None = None,
is_eu: bool | None = None,
is_private: bool | None = None,
) -> select:
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
conditions = [Contact.deleted_at.is_(None)]
if search:
search_pattern = f"%{search}%"
conditions.append(
or_(
Contact.company_name.ilike(search_pattern),
Contact.address_city.ilike(search_pattern),
Contact.email.ilike(search_pattern),
Contact.vat_id.ilike(search_pattern),
)
)
if role:
# When filtering by 'kaeufer' or 'verkaeufer', include 'beide' as well
if role in ("kaeufer", "verkaeufer"):
conditions.append(
or_(
Contact.role == role,
Contact.role == "beide",
)
)
else:
conditions.append(Contact.role == role)
if is_eu is not None:
if is_eu:
# EU = address_country != 'DE'
conditions.append(Contact.address_country != "DE")
else:
# Inland = address_country == 'DE'
conditions.append(Contact.address_country == "DE")
if is_private is not None:
conditions.append(Contact.is_private == is_private)
return stmt.where(and_(*conditions))
def _apply_sort(stmt: select, sort: str | None = None) -> select:
"""Apply ORDER BY to a select statement based on sort param.
Format: 'field' for ascending, '-field' for descending.
"""
if not sort:
return stmt.order_by(Contact.created_at.desc())
descending = sort.startswith("-")
field_name = sort.lstrip("-")
if field_name not in _SORTABLE_FIELDS:
return stmt.order_by(Contact.created_at.desc())
column = getattr(Contact, field_name)
if descending:
return stmt.order_by(column.desc())
return stmt.order_by(column.asc())
async def list_contacts(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
search: str | None = None,
role: str | None = None,
is_eu: bool | None = None,
is_private: bool | None = None,
sort: str | None = None,
) -> tuple[list[Contact], int]:
"""List contacts with pagination, filtering, and sorting.
Returns (contacts, total_count).
"""
# Build count query
count_stmt = select(func.count(Contact.id))
count_stmt = _apply_filters(
count_stmt,
search=search,
role=role,
is_eu=is_eu,
is_private=is_private,
)
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Build data query with eager-loaded contact persons
data_stmt = select(Contact).options(selectinload(Contact.contact_persons))
data_stmt = _apply_filters(
data_stmt,
search=search,
role=role,
is_eu=is_eu,
is_private=is_private,
)
data_stmt = _apply_sort(data_stmt, sort)
offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size)
result = await db.execute(data_stmt)
contacts = list(result.scalars().all())
return contacts, total
async def get_contact_by_id(
db: AsyncSession, contact_id: uuid.UUID
) -> Contact | None:
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
stmt = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
and_(Contact.id == contact_id, Contact.deleted_at.is_(None))
)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_contact(
db: AsyncSession, data: dict[str, Any]
) -> Contact:
"""Create a new contact with optional nested contact persons.
The data dict may contain a 'contact_persons' list of dicts.
"""
persons_data = data.pop("contact_persons", [])
contact = Contact(**data)
db.add(contact)
await db.flush() # Flush to generate contact.id
# Add contact persons if provided
for person_data in persons_data:
person = ContactPerson(
contact_id=contact.id,
name=person_data["name"],
function=person_data.get("function"),
phone=person_data.get("phone"),
email=person_data.get("email"),
)
db.add(person)
await db.flush()
await db.refresh(contact)
return contact
async def update_contact(
db: AsyncSession, contact_id: uuid.UUID, updates: dict[str, Any]
) -> Contact | None:
"""Update a contact's fields. Returns None if not found or deleted."""
contact = await get_contact_by_id(db, contact_id)
if contact is None:
return None
for key, value in updates.items():
if hasattr(contact, key):
setattr(contact, key, value)
await db.flush()
await db.refresh(contact)
return contact
async def soft_delete_contact(
db: AsyncSession, contact_id: uuid.UUID
) -> Contact | None:
"""Soft-delete a contact by setting deleted_at. Returns None if not found."""
contact = await get_contact_by_id(db, contact_id)
if contact is None:
return None
contact.deleted_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(contact)
return contact
async def add_contact_person(
db: AsyncSession,
contact_id: uuid.UUID,
person_data: dict[str, Any],
) -> ContactPerson | None:
"""Add a contact person to an existing contact.
Returns None if the contact is not found or is deleted.
"""
contact = await get_contact_by_id(db, contact_id)
if contact is None:
return None
person = ContactPerson(
contact_id=contact_id,
name=person_data["name"],
function=person_data.get("function"),
phone=person_data.get("phone"),
email=person_data.get("email"),
)
db.add(person)
await db.flush()
await db.refresh(person)
return person
async def remove_contact_person(
db: AsyncSession,
contact_id: uuid.UUID,
person_id: uuid.UUID,
) -> bool:
"""Remove (hard-delete) a contact person from a contact.
Returns True if the person was found and deleted, False otherwise.
"""
stmt = select(ContactPerson).where(
and_(
ContactPerson.id == person_id,
ContactPerson.contact_id == contact_id,
)
)
result = await db.execute(stmt)
person = result.scalar_one_or_none()
if person is None:
return False
await db.delete(person)
await db.flush()
return True
+114
View File
@@ -0,0 +1,114 @@
"""USt-IdNr. (VAT ID) format validation for DE and common EU countries.
Provides basic regex-based format validation. Does NOT perform live API verification.
"""
import re
# Country-specific VAT ID regex patterns.
# Each pattern validates the format after the 2-letter country code prefix.
_VAT_PATTERNS: dict[str, re.Pattern[str]] = {
# DE: DE + 9 digits (e.g. DE123456789)
"DE": re.compile(r"^DE\d{9}$"),
# AT: AT + U + 8 digits (e.g. ATU12345678)
"AT": re.compile(r"^ATU\d{8}$"),
# FR: FR + 2 alphanumeric + 9 digits (e.g. FRAB123456789)
"FR": re.compile(r"^FR[A-Za-z0-9]{2}\d{9}$"),
# NL: NL + 9 digits + B + 2 digits (e.g. NL123456789B01)
"NL": re.compile(r"^NL\d{9}B\d{2}$"),
# PL: PL + 10 digits (e.g. PL1234567890)
"PL": re.compile(r"^PL\d{10}$"),
# CZ: CZ + 8-10 digits (e.g. CZ1234567890)
"CZ": re.compile(r"^CZ\d{8,10}$"),
# IT: IT + 11 digits (e.g. IT12345678901)
"IT": re.compile(r"^IT\d{11}$"),
# ES: ES + 1 alphanumeric + 7 digits + 1 alphanumeric (e.g. ESA1234567B)
"ES": re.compile(r"^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$"),
# BE: BE + 10 digits (e.g. BE1234567890)
"BE": re.compile(r"^BE\d{10}$"),
# DK: DK + 8 digits (e.g. DK12345678)
"DK": re.compile(r"^DK\d{8}$"),
# SE: SE + 10 digits (e.g. SE1234567890)
"SE": re.compile(r"^SE\d{10}$"),
}
# Fallback pattern for EU countries not explicitly listed above.
# Accepts <2-letter country code> + 5-15 alphanumeric characters.
_EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
# Set of supported EU country codes (ISO 3166-1 alpha-2).
_EU_COUNTRY_CODES: set[str] = {
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR",
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
"SE", "SI", "SK",
}
def get_country_code_from_vat_id(vat_id: str) -> str | None:
"""Extract the 2-letter country code from a VAT ID string.
Returns None if the string is too short or does not start with letters.
"""
if not vat_id or len(vat_id) < 3:
return None
prefix = vat_id[:2].upper()
if not prefix.isalpha():
return None
return prefix
def validate_vat_id(vat_id: str) -> bool:
"""Validate the format of a VAT ID (USt-IdNr.).
Supports DE and common EU countries with specific regex patterns.
For other EU countries, a fallback pattern is used.
Non-EU or unrecognised formats return False.
Args:
vat_id: The VAT ID string to validate (case-insensitive, will be uppercased).
Returns:
True if the format is valid, False otherwise.
"""
if not vat_id:
return True # Empty VAT ID is valid (nullable field)
normalized = vat_id.strip().upper().replace(" ", "")
country_code = get_country_code_from_vat_id(normalized)
if country_code is None:
return False
# Check against country-specific pattern if available
pattern = _VAT_PATTERNS.get(country_code)
if pattern is not None:
return bool(pattern.match(normalized))
# Fallback for EU countries without a specific pattern
if country_code in _EU_COUNTRY_CODES:
return bool(_EU_FALLBACK_PATTERN.match(normalized))
# Non-EU country code: reject (this module validates EU VAT IDs only)
return False
def validate_vat_id_or_raise(vat_id: str | None) -> str | None:
"""Validate VAT ID format and return the normalised value.
Raises ValueError if the format is invalid.
Args:
vat_id: The VAT ID string to validate, or None.
Returns:
The normalised (uppercased, whitespace-stripped) VAT ID, or None.
Raises:
ValueError: If the VAT ID format is invalid.
"""
if vat_id is None or vat_id == "":
return None
normalized = vat_id.strip().upper().replace(" ", "")
if not validate_vat_id(normalized):
raise ValueError(f"Invalid VAT ID format: '{vat_id}'")
return normalized
+829
View File
@@ -0,0 +1,829 @@
"""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