fix: ruff lint + format fixes in tests, ESLint fixes in frontend

This commit is contained in:
2026-07-17 21:28:58 +02:00
parent 341d0c6f38
commit fbb1b39b57
56 changed files with 1399 additions and 721 deletions
+1
View File
@@ -12,6 +12,7 @@ from app.config import settings
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Declarative base for all SQLAlchemy models.""" """Declarative base for all SQLAlchemy models."""
pass pass
+31 -8
View File
@@ -1,5 +1,4 @@
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement. """FastAPI dependencies: pagination, current user extraction, RBAC role enforcement."""
"""
import uuid import uuid
from typing import Optional from typing import Optional
@@ -35,7 +34,12 @@ async def get_current_user(
if credentials is None: if credentials is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}}, detail={
"error": {
"code": "UNAUTHORIZED",
"message": "Missing authentication token",
}
},
) )
token = credentials.credentials token = credentials.credentials
@@ -43,14 +47,21 @@ async def get_current_user(
if payload is None: if payload is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}}, detail={
"error": {
"code": "INVALID_TOKEN",
"message": "Invalid or expired access token",
}
},
) )
user_id_str = payload.get("sub") user_id_str = payload.get("sub")
if not user_id_str: if not user_id_str:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}}, detail={
"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}
},
) )
try: try:
@@ -58,7 +69,12 @@ async def get_current_user(
except (ValueError, TypeError): except (ValueError, TypeError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}}, detail={
"error": {
"code": "INVALID_TOKEN",
"message": "Invalid user ID in token",
}
},
) )
user = await get_user_by_id(db, user_uuid) user = await get_user_by_id(db, user_uuid)
@@ -71,7 +87,12 @@ async def get_current_user(
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}}, detail={
"error": {
"code": "ACCOUNT_DISABLED",
"message": "Account is deactivated",
}
},
) )
return user return user
@@ -87,7 +108,9 @@ def require_role(allowed_roles: list[str]):
""" """
async def role_checker(user: User = Depends(get_current_user)) -> User: async def role_checker(user: User = Depends(get_current_user)) -> User:
user_role = user.role.value if isinstance(user.role, UserRole) else str(user.role) user_role = (
user.role.value if isinstance(user.role, UserRole) else str(user.role)
)
if user_role not in allowed_roles: if user_role not in allowed_roles:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
+13 -1
View File
@@ -9,7 +9,18 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, contacts, copilot, datev, files, image_retouch, ocr, sales, users, vehicles from app.routers import (
auth,
contacts,
copilot,
datev,
files,
image_retouch,
ocr,
sales,
users,
vehicles,
)
@asynccontextmanager @asynccontextmanager
@@ -49,6 +60,7 @@ api_v1_router.include_router(datev.router)
api_v1_router.include_router(copilot.router) api_v1_router.include_router(copilot.router)
api_v1_router.include_router(image_retouch.router) api_v1_router.include_router(image_retouch.router)
# Health endpoint (no auth required) # Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"]) @api_v1_router.get("/health", tags=["health"])
async def health_check(): async def health_check():
+54 -35
View File
@@ -56,46 +56,67 @@ class Contact(Base):
default=uuid.uuid4, default=uuid.uuid4,
) )
company_name: Mapped[str] = mapped_column( company_name: Mapped[str] = mapped_column(
String(255), nullable=False, index=True, String(255),
nullable=False,
index=True,
) )
legal_form: Mapped[str | None] = mapped_column( legal_form: Mapped[str | None] = mapped_column(
String(50), nullable=True, String(50),
nullable=True,
) )
address_street: Mapped[str | None] = mapped_column( address_street: Mapped[str | None] = mapped_column(
String(255), nullable=True, String(255),
nullable=True,
) )
address_zip: Mapped[str | None] = mapped_column( address_zip: Mapped[str | None] = mapped_column(
String(10), nullable=True, String(10),
nullable=True,
) )
address_city: Mapped[str | None] = mapped_column( address_city: Mapped[str | None] = mapped_column(
String(100), nullable=True, String(100),
nullable=True,
) )
address_country: Mapped[str] = mapped_column( address_country: Mapped[str] = mapped_column(
String(2), nullable=False, default="DE", index=True, String(2),
nullable=False,
default="DE",
index=True,
) )
vat_id: Mapped[str | None] = mapped_column( vat_id: Mapped[str | None] = mapped_column(
String(20), nullable=True, String(20),
nullable=True,
) )
phone: Mapped[str | None] = mapped_column( phone: Mapped[str | None] = mapped_column(
String(50), nullable=True, String(50),
nullable=True,
) )
email: Mapped[str | None] = mapped_column( email: Mapped[str | None] = mapped_column(
String(255), nullable=True, String(255),
nullable=True,
) )
website: Mapped[str | None] = mapped_column( website: Mapped[str | None] = mapped_column(
String(255), nullable=True, String(255),
nullable=True,
) )
role: Mapped[str] = mapped_column( role: Mapped[str] = mapped_column(
String(20), nullable=False, index=True, String(20),
nullable=False,
index=True,
) )
vat_id_status: Mapped[str] = mapped_column( vat_id_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="ungeprueft", String(20),
nullable=False,
default="ungeprueft",
) )
is_private: Mapped[bool] = mapped_column( is_private: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, Boolean,
nullable=False,
default=False,
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), DateTime(timezone=True),
nullable=False,
server_default=func.now(),
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -104,7 +125,9 @@ class Contact(Base):
onupdate=func.now(), onupdate=func.now(),
) )
deleted_at: Mapped[datetime | None] = mapped_column( deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True, DateTime(timezone=True),
nullable=True,
index=True,
) )
contact_persons: Mapped[list["ContactPerson"]] = relationship( contact_persons: Mapped[list["ContactPerson"]] = relationship(
@@ -133,18 +156,10 @@ class Contact(Base):
"role": self.role, "role": self.role,
"vat_id_status": self.vat_id_status, "vat_id_status": self.vat_id_status,
"is_private": self.is_private, "is_private": self.is_private,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
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),
"updated_at": ( "contact_persons": [p.to_dict() for p in (self.contact_persons or [])],
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 [])
],
} }
@@ -165,19 +180,25 @@ class ContactPerson(Base):
index=True, index=True,
) )
name: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(
String(255), nullable=False, String(255),
nullable=False,
) )
function: Mapped[str | None] = mapped_column( function: Mapped[str | None] = mapped_column(
String(100), nullable=True, String(100),
nullable=True,
) )
phone: Mapped[str | None] = mapped_column( phone: Mapped[str | None] = mapped_column(
String(50), nullable=True, String(50),
nullable=True,
) )
email: Mapped[str | None] = mapped_column( email: Mapped[str | None] = mapped_column(
String(255), nullable=True, String(255),
nullable=True,
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), DateTime(timezone=True),
nullable=False,
server_default=func.now(),
) )
contact: Mapped["Contact"] = relationship(back_populates="contact_persons") contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
@@ -194,7 +215,5 @@ class ContactPerson(Base):
"function": self.function, "function": self.function,
"phone": self.phone, "phone": self.phone,
"email": self.email, "email": self.email,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
self.created_at.isoformat() if self.created_at else None
),
} }
+22 -7
View File
@@ -16,6 +16,7 @@ from app.database import Base
class CopilotRole(str, enum.Enum): class CopilotRole(str, enum.Enum):
"""Roles for chat messages.""" """Roles for chat messages."""
user = "user" user = "user"
assistant = "assistant" assistant = "assistant"
@@ -37,10 +38,14 @@ class CopilotSession(Base):
index=True, index=True,
) )
title: Mapped[str] = mapped_column( title: Mapped[str] = mapped_column(
String(255), nullable=False, default="Neue Konversation", String(255),
nullable=False,
default="Neue Konversation",
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), DateTime(timezone=True),
nullable=False,
server_default=func.now(),
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -57,7 +62,9 @@ class CopilotSession(Base):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>" return (
f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>"
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Serialize session for API responses.""" """Serialize session for API responses."""
@@ -99,16 +106,22 @@ class CopilotChat(Base):
) )
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
actions: Mapped[list | None] = mapped_column( actions: Mapped[list | None] = mapped_column(
JSONB, nullable=True, default=None, JSONB,
nullable=True,
default=None,
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), DateTime(timezone=True),
nullable=False,
server_default=func.now(),
) )
session: Mapped["CopilotSession"] = relationship(back_populates="messages") session: Mapped["CopilotSession"] = relationship(back_populates="messages")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>" return (
f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>"
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Serialize chat message for API responses.""" """Serialize chat message for API responses."""
@@ -116,7 +129,9 @@ class CopilotChat(Base):
"id": str(self.id), "id": str(self.id),
"session_id": str(self.session_id), "session_id": str(self.session_id),
"user_id": str(self.user_id), "user_id": str(self.user_id),
"role": self.role.value if isinstance(self.role, CopilotRole) else self.role, "role": self.role.value
if isinstance(self.role, CopilotRole)
else self.role,
"content": self.content, "content": self.content,
"actions": self.actions, "actions": self.actions,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
+1 -3
View File
@@ -23,9 +23,7 @@ class DATEVExport(Base):
) )
start_date: Mapped[date] = mapped_column(Date, nullable=False) start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False) end_date: Mapped[date] = mapped_column(Date, nullable=False)
file_path: Mapped[str | None] = mapped_column( file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
String(500), nullable=True
)
total_amount: Mapped[Decimal] = mapped_column( total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, default=0 Numeric(14, 2), nullable=False, default=0
) )
+9 -27
View File
@@ -30,24 +30,12 @@ class File(Base):
nullable=False, nullable=False,
index=True, index=True,
) )
original_filename: Mapped[str] = mapped_column( original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
String(255), nullable=False stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
) file_path: Mapped[str] = mapped_column(String(512), nullable=False)
stored_filename: Mapped[str] = mapped_column( mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
String(255), nullable=False file_size: Mapped[int] = mapped_column(Integer, nullable=False)
) thumbnail_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
file_path: Mapped[str] = mapped_column(
String(512), nullable=False
)
mime_type: Mapped[str] = mapped_column(
String(100), nullable=False
)
file_size: Mapped[int] = mapped_column(
Integer, nullable=False
)
thumbnail_path: Mapped[str | None] = mapped_column(
String(512), nullable=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
@@ -60,9 +48,7 @@ class File(Base):
onupdate=func.now(), onupdate=func.now(),
) )
vehicle: Mapped["Vehicle"] = relationship( vehicle: Mapped["Vehicle"] = relationship("Vehicle", back_populates="files")
"Vehicle", back_populates="files"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<File id={self.id} vehicle_id={self.vehicle_id} filename={self.original_filename}>" return f"<File id={self.id} vehicle_id={self.vehicle_id} filename={self.original_filename}>"
@@ -78,10 +64,6 @@ class File(Base):
"mime_type": self.mime_type, "mime_type": self.mime_type,
"file_size": self.file_size, "file_size": self.file_size,
"thumbnail_path": self.thumbnail_path, "thumbnail_path": self.thumbnail_path,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
self.created_at.isoformat() if self.created_at else None "updated_at": (self.updated_at.isoformat() if self.updated_at else None),
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
} }
+6 -2
View File
@@ -38,7 +38,9 @@ class OCRResult(Base):
) )
file_path: Mapped[str] = mapped_column(String(512), nullable=False) file_path: Mapped[str] = mapped_column(String(512), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False) file_name: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False, default="image/png") mime_type: Mapped[str] = mapped_column(
String(100), nullable=False, default="image/png"
)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
Enum(OCRStatus, name="ocr_status", create_constraint=True), Enum(OCRStatus, name="ocr_status", create_constraint=True),
nullable=False, nullable=False,
@@ -80,7 +82,9 @@ class OCRResult(Base):
"file_path": self.file_path, "file_path": self.file_path,
"file_name": self.file_name, "file_name": self.file_name,
"mime_type": self.mime_type, "mime_type": self.mime_type,
"status": self.status.value if isinstance(self.status, OCRStatus) else str(self.status), "status": self.status.value
if isinstance(self.status, OCRStatus)
else str(self.status),
"raw_text": self.raw_text, "raw_text": self.raw_text,
"structured_data": self.structured_data, "structured_data": self.structured_data,
"confidence_score": self.confidence_score, "confidence_score": self.confidence_score,
+7 -21
View File
@@ -38,24 +38,14 @@ class RetouchResult(Base):
nullable=True, nullable=True,
index=True, index=True,
) )
original_file_path: Mapped[str] = mapped_column( original_file_path: Mapped[str] = mapped_column(String(512), nullable=False)
String(512), nullable=False original_file_name: Mapped[str] = mapped_column(String(255), nullable=False)
)
original_file_name: Mapped[str] = mapped_column(
String(255), nullable=False
)
mime_type: Mapped[str] = mapped_column( mime_type: Mapped[str] = mapped_column(
String(100), nullable=False, default="image/png" String(100), nullable=False, default="image/png"
) )
retouched_file_path: Mapped[str | None] = mapped_column( retouched_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
String(512), nullable=True status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
)
error_message: Mapped[str | None] = mapped_column(
Text, nullable=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
@@ -84,10 +74,6 @@ class RetouchResult(Base):
"retouched_file_path": self.retouched_file_path, "retouched_file_path": self.retouched_file_path,
"status": self.status, "status": self.status,
"error_message": self.error_message, "error_message": self.error_message,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
self.created_at.isoformat() if self.created_at else None "updated_at": (self.updated_at.isoformat() if self.updated_at else None),
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
} }
+7 -13
View File
@@ -66,21 +66,13 @@ class Sale(Base):
nullable=True, nullable=True,
index=True, index=True,
) )
sale_price: Mapped[Decimal] = mapped_column( sale_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
Numeric(12, 2), nullable=False
)
sale_date: Mapped[date] = mapped_column( sale_date: Mapped[date] = mapped_column(
Date, nullable=False, default=func.current_date() Date, nullable=False, default=func.current_date()
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
String(20), nullable=False, default="draft" is_gwg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
) contract_pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_gwg: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
contract_pdf_path: Mapped[str | None] = mapped_column(
String(500), nullable=True
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -115,7 +107,9 @@ class Sale(Base):
"seller_contact_id": ( "seller_contact_id": (
str(self.seller_contact_id) if self.seller_contact_id else None str(self.seller_contact_id) if self.seller_contact_id else None
), ),
"sale_price": float(self.sale_price) if self.sale_price is not None else None, "sale_price": float(self.sale_price)
if self.sale_price is not None
else None,
"sale_date": self.sale_date.isoformat() if self.sale_date else None, "sale_date": self.sale_date.isoformat() if self.sale_date else None,
"status": self.status, "status": self.status,
"is_gwg": self.is_gwg, "is_gwg": self.is_gwg,
+15 -5
View File
@@ -13,6 +13,7 @@ from app.database import Base
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
"""User roles for RBAC.""" """User roles for RBAC."""
admin = "admin" admin = "admin"
verkaeufer = "verkaeufer" verkaeufer = "verkaeufer"
buchhaltung = "buchhaltung" buchhaltung = "buchhaltung"
@@ -29,13 +30,18 @@ class User(Base):
default=uuid.uuid4, default=uuid.uuid4,
) )
email: Mapped[str] = mapped_column( email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True, String(255),
unique=True,
nullable=False,
index=True,
) )
password_hash: Mapped[str] = mapped_column( password_hash: Mapped[str] = mapped_column(
String(255), nullable=False, String(255),
nullable=False,
) )
full_name: Mapped[str] = mapped_column( full_name: Mapped[str] = mapped_column(
String(200), nullable=False, String(200),
nullable=False,
) )
role: Mapped[UserRole] = mapped_column( role: Mapped[UserRole] = mapped_column(
Enum(UserRole, name="user_role"), Enum(UserRole, name="user_role"),
@@ -43,10 +49,14 @@ class User(Base):
default=UserRole.verkaeufer, default=UserRole.verkaeufer,
) )
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(5), nullable=False, default="de", String(5),
nullable=False,
default="de",
) )
is_active: Mapped[bool] = mapped_column( is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, Boolean,
nullable=False,
default=True,
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
+13 -39
View File
@@ -56,12 +56,8 @@ class Vehicle(Base):
__tablename__ = "vehicles" __tablename__ = "vehicles"
__table_args__ = ( __table_args__ = (
CheckConstraint( CheckConstraint("char_length(fin) = 17", name="ck_vehicles_fin_length"),
"char_length(fin) = 17", name="ck_vehicles_fin_length" CheckConstraint("condition IN ('new', 'used')", name="ck_vehicles_condition"),
),
CheckConstraint(
"condition IN ('new', 'used')", name="ck_vehicles_condition"
),
CheckConstraint( CheckConstraint(
"availability IN ('available', 'reserved', 'sold')", "availability IN ('available', 'reserved', 'sold')",
name="ck_vehicles_availability", name="ck_vehicles_availability",
@@ -87,24 +83,18 @@ class Vehicle(Base):
String(17), unique=True, nullable=False, index=True String(17), unique=True, nullable=False, index=True
) )
year: Mapped[int | None] = mapped_column(Integer, nullable=True) year: Mapped[int | None] = mapped_column(Integer, nullable=True)
first_registration: Mapped[date | None] = mapped_column( first_registration: Mapped[date | None] = mapped_column(Date, nullable=True)
Date, nullable=True
)
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True) power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True) power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True) fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True) transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
color: Mapped[str | None] = mapped_column(String(50), nullable=True) color: Mapped[str | None] = mapped_column(String(50), nullable=True)
condition: Mapped[str] = mapped_column( condition: Mapped[str] = mapped_column(String(20), nullable=False, default="used")
String(20), nullable=False, default="used"
)
location: Mapped[str | None] = mapped_column(String(255), nullable=True) location: Mapped[str | None] = mapped_column(String(255), nullable=True)
availability: Mapped[str] = mapped_column( availability: Mapped[str] = mapped_column(
String(20), nullable=False, default="available" String(20), nullable=False, default="available"
) )
price: Mapped[Decimal] = mapped_column( price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
Numeric(12, 2), nullable=False
)
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False) vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True) lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True) machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
@@ -112,9 +102,7 @@ class Vehicle(Base):
operating_hours: Mapped[Decimal | None] = mapped_column( operating_hours: Mapped[Decimal | None] = mapped_column(
Numeric(12, 1), nullable=True Numeric(12, 1), nullable=True
) )
operating_hours_unit: Mapped[str | None] = mapped_column( operating_hours_unit: Mapped[str | None] = mapped_column(String(5), nullable=True)
String(5), nullable=True
)
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True) mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
@@ -149,9 +137,7 @@ class Vehicle(Base):
"fin": self.fin, "fin": self.fin,
"year": self.year, "year": self.year,
"first_registration": ( "first_registration": (
self.first_registration.isoformat() self.first_registration.isoformat() if self.first_registration else None
if self.first_registration
else None
), ),
"power_kw": self.power_kw, "power_kw": self.power_kw,
"power_hp": self.power_hp, "power_hp": self.power_hp,
@@ -174,15 +160,9 @@ class Vehicle(Base):
"operating_hours_unit": self.operating_hours_unit, "operating_hours_unit": self.operating_hours_unit,
"mileage_km": self.mileage_km, "mileage_km": self.mileage_km,
"description": self.description, "description": self.description,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
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),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
"deleted_at": (
self.deleted_at.isoformat() if self.deleted_at else None
),
} }
@@ -232,14 +212,8 @@ class MobileDeListing(Base):
"vehicle_id": str(self.vehicle_id), "vehicle_id": str(self.vehicle_id),
"ad_id": self.ad_id, "ad_id": self.ad_id,
"sync_status": self.sync_status, "sync_status": self.sync_status,
"synced_at": ( "synced_at": (self.synced_at.isoformat() if self.synced_at else None),
self.synced_at.isoformat() if self.synced_at else None
),
"error_log": self.error_log, "error_log": self.error_log,
"created_at": ( "created_at": (self.created_at.isoformat() if self.created_at else None),
self.created_at.isoformat() if self.created_at else None "updated_at": (self.updated_at.isoformat() if self.updated_at else None),
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
} }
+33 -10
View File
@@ -25,8 +25,12 @@ router = APIRouter(prefix="/contacts", tags=["contacts"])
async def list_contacts( async def list_contacts(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination), pagination: dict = Depends(get_pagination),
search: str | None = Query(None, description="Search in company_name, city, email, vat_id"), search: str | None = Query(
role: str | None = Query(None, description="Filter by role (kaeufer, verkaeufer, beide)"), 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_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
is_private: bool | None = Query(None, description="Filter private contacts"), is_private: bool | None = Query(None, description="Filter private contacts"),
sort: str | None = Query(None, description="Sort field (prefix - for descending)"), sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
@@ -66,7 +70,9 @@ async def create_contact(
return ContactResponse.model_validate(contact) return ContactResponse.model_validate(contact)
@router.get("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK) @router.get(
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
)
async def get_contact( async def get_contact(
contact_id: uuid.UUID, contact_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -77,12 +83,16 @@ async def get_contact(
if contact is None: if contact is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}}, detail={
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
},
) )
return ContactResponse.model_validate(contact) return ContactResponse.model_validate(contact)
@router.put("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK) @router.put(
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
)
async def update_contact( async def update_contact(
contact_id: uuid.UUID, contact_id: uuid.UUID,
body: ContactUpdate, body: ContactUpdate,
@@ -100,12 +110,16 @@ async def update_contact(
if contact is None: if contact is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}}, detail={
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
},
) )
return ContactResponse.model_validate(contact) return ContactResponse.model_validate(contact)
@router.delete("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK) @router.delete(
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
)
async def delete_contact( async def delete_contact(
contact_id: uuid.UUID, contact_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -116,7 +130,9 @@ async def delete_contact(
if contact is None: if contact is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}}, detail={
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
},
) )
return ContactResponse.model_validate(contact) return ContactResponse.model_validate(contact)
@@ -139,7 +155,9 @@ async def add_contact_person(
if person is None: if person is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}}, detail={
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
},
) )
return ContactPersonResponse.model_validate(person) return ContactPersonResponse.model_validate(person)
@@ -159,6 +177,11 @@ async def remove_contact_person(
if not deleted: if not deleted:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "PERSON_NOT_FOUND", "message": "Contact person not found"}}, detail={
"error": {
"code": "PERSON_NOT_FOUND",
"message": "Contact person not found",
}
},
) )
return None return None
+3 -1
View File
@@ -68,7 +68,9 @@ async def copilot_action(
return ActionResponse(**result) return ActionResponse(**result)
@router.get("/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK) @router.get(
"/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK
)
async def copilot_history( async def copilot_history(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination), pagination: dict = Depends(get_pagination),
+12 -3
View File
@@ -19,7 +19,9 @@ from app.services import datev_service
router = APIRouter(prefix="/datev", tags=["datev"]) router = APIRouter(prefix="/datev", tags=["datev"])
@router.post("/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED) @router.post(
"/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED
)
async def create_export( async def create_export(
body: DATEVExportCreate, body: DATEVExportCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -37,7 +39,9 @@ async def create_export(
return DATEVExportResponse.model_validate(export) return DATEVExportResponse.model_validate(export)
@router.get("/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK) @router.get(
"/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK
)
async def list_exports( async def list_exports(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination), pagination: dict = Depends(get_pagination),
@@ -68,7 +72,12 @@ async def download_export(
if result is None: if result is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "EXPORT_NOT_FOUND", "message": "DATEV export not found"}}, detail={
"error": {
"code": "EXPORT_NOT_FOUND",
"message": "DATEV export not found",
}
},
) )
filename, csv_bytes = result filename, csv_bytes = result
return Response( return Response(
+9 -4
View File
@@ -3,7 +3,14 @@
import os import os
import uuid import uuid
from fastapi import APIRouter, Depends, File as FastAPIFile, HTTPException, UploadFile, status from fastapi import (
APIRouter,
Depends,
File as FastAPIFile,
HTTPException,
UploadFile,
status,
)
from fastapi.responses import FileResponse as FastAPIFileResponse from fastapi.responses import FileResponse as FastAPIFileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,9 +29,7 @@ from app.services import file_service, vehicle_service
router = APIRouter(prefix="/vehicles", tags=["files"]) router = APIRouter(prefix="/vehicles", tags=["files"])
async def _verify_vehicle_exists( async def _verify_vehicle_exists(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle:
db: AsyncSession, vehicle_id: uuid.UUID
) -> Vehicle:
"""Verify that a vehicle exists, raising 404 if not.""" """Verify that a vehicle exists, raising 404 if not."""
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id) vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
if vehicle is None: if vehicle is None:
+25 -4
View File
@@ -2,7 +2,16 @@
import uuid import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
Form,
HTTPException,
UploadFile,
status,
)
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -79,7 +88,12 @@ async def process_image(
except (ValueError, TypeError): except (ValueError, TypeError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}}, detail={
"error": {
"code": "INVALID_VEHICLE_ID",
"message": "Invalid vehicle UUID",
}
},
) )
# Fetch vehicle info for better retouch prompt # Fetch vehicle info for better retouch prompt
@@ -112,7 +126,9 @@ async def process_image(
return RetouchProcessResponse( return RetouchProcessResponse(
message="Retouch processing queued", message="Retouch processing queued",
retouch_id=result.id, retouch_id=result.id,
status=result.status.value if isinstance(result.status, RetouchStatus) else str(result.status), status=result.status.value
if isinstance(result.status, RetouchStatus)
else str(result.status),
) )
@@ -136,7 +152,12 @@ async def get_retouch_result(
if result is None: if result is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "RETOUCH_NOT_FOUND", "message": "Retouch result not found"}}, detail={
"error": {
"code": "RETOUCH_NOT_FOUND",
"message": "Retouch result not found",
}
},
) )
return RetouchResultResponse.model_validate(result) return RetouchResultResponse.model_validate(result)
+26 -5
View File
@@ -2,7 +2,17 @@
import uuid import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
Form,
HTTPException,
Query,
UploadFile,
status,
)
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -78,7 +88,12 @@ async def upload_scan(
except (ValueError, TypeError): except (ValueError, TypeError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}}, detail={
"error": {
"code": "INVALID_VEHICLE_ID",
"message": "Invalid vehicle UUID",
}
},
) )
try: try:
@@ -101,7 +116,9 @@ async def upload_scan(
return OCRUploadResponse( return OCRUploadResponse(
message="OCR processing queued", message="OCR processing queued",
ocr_result_id=ocr_result.id, ocr_result_id=ocr_result.id,
status=ocr_result.status.value if isinstance(ocr_result.status, OCRStatus) else str(ocr_result.status), status=ocr_result.status.value
if isinstance(ocr_result.status, OCRStatus)
else str(ocr_result.status),
) )
@@ -120,7 +137,9 @@ async def get_ocr_result(
if ocr_result is None: if ocr_result is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}}, detail={
"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}
},
) )
return OCRResultResponse.model_validate(ocr_result) return OCRResultResponse.model_validate(ocr_result)
@@ -164,7 +183,9 @@ async def apply_ocr_to_vehicle(
): ):
"""Apply OCR structured data to the linked vehicle.""" """Apply OCR structured data to the linked vehicle."""
try: try:
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(db, result_id) ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
db, result_id
)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
+40 -9
View File
@@ -27,9 +27,15 @@ router = APIRouter(prefix="/sales", tags=["sales"])
async def list_sales( async def list_sales(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination), pagination: dict = Depends(get_pagination),
status_filter: str | None = Query(None, alias="status", description="Filter by sale status"), status_filter: str | None = Query(
date_from: str | None = Query(None, description="Filter sales from this date (YYYY-MM-DD)"), None, alias="status", description="Filter by sale status"
date_to: str | None = Query(None, description="Filter sales up to this date (YYYY-MM-DD)"), ),
date_from: str | None = Query(
None, description="Filter sales from this date (YYYY-MM-DD)"
),
date_to: str | None = Query(
None, description="Filter sales up to this date (YYYY-MM-DD)"
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""List sales with pagination, filtering by status and date range.""" """List sales with pagination, filtering by status and date range."""
@@ -43,7 +49,12 @@ async def list_sales(
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_from format: {date_from}"}}, detail={
"error": {
"code": "INVALID_DATE",
"message": f"Invalid date_from format: {date_from}",
}
},
) )
if date_to: if date_to:
try: try:
@@ -51,7 +62,12 @@ async def list_sales(
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_to format: {date_to}"}}, detail={
"error": {
"code": "INVALID_DATE",
"message": f"Invalid date_to format: {date_to}",
}
},
) )
sales, total = await sale_service.list_sales( sales, total = await sale_service.list_sales(
@@ -127,7 +143,9 @@ async def update_sale(
return SaleResponse.model_validate(sale) return SaleResponse.model_validate(sale)
@router.delete("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK) @router.delete(
"/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK
)
async def delete_sale( async def delete_sale(
sale_id: uuid.UUID, sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -143,7 +161,11 @@ async def delete_sale(
return SaleResponse.model_validate(sale) return SaleResponse.model_validate(sale)
@router.post("/{sale_id}/contract", response_model=ContractResponse, status_code=status.HTTP_200_OK) @router.post(
"/{sale_id}/contract",
response_model=ContractResponse,
status_code=status.HTTP_200_OK,
)
async def regenerate_contract( async def regenerate_contract(
sale_id: uuid.UUID, sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -179,7 +201,12 @@ async def download_contract(
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path): if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTRACT_NOT_FOUND", "message": "Contract PDF not generated yet"}}, detail={
"error": {
"code": "CONTRACT_NOT_FOUND",
"message": "Contract PDF not generated yet",
}
},
) )
return FileResponse( return FileResponse(
path=sale.contract_pdf_path, path=sale.contract_pdf_path,
@@ -188,7 +215,11 @@ async def download_contract(
) )
@router.post("/{sale_id}/verify-ust-id", response_model=UstIdVerifyResponse, status_code=status.HTTP_200_OK) @router.post(
"/{sale_id}/verify-ust-id",
response_model=UstIdVerifyResponse,
status_code=status.HTTP_200_OK,
)
async def verify_ust_id( async def verify_ust_id(
sale_id: uuid.UUID, sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+3 -1
View File
@@ -89,7 +89,9 @@ async def update_user(
return UserResponse.model_validate(user) return UserResponse.model_validate(user)
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK) @router.delete(
"/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK
)
async def delete_user( async def delete_user(
user_id: uuid.UUID, user_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+27 -9
View File
@@ -30,7 +30,9 @@ async def list_vehicles(
availability: str | None = Query(None, description="Filter by availability"), availability: str | None = Query(None, description="Filter by availability"),
min_price: float | None = Query(None, ge=0, description="Minimum price"), min_price: float | None = Query(None, ge=0, description="Minimum price"),
max_price: float | None = Query(None, ge=0, description="Maximum price"), max_price: float | None = Query(None, ge=0, description="Maximum price"),
search: str | None = Query(None, description="Search in make, model, fin, location"), search: str | None = Query(
None, description="Search in make, model, fin, location"
),
sort: str | None = Query(None, description="Sort field (prefix - for descending)"), sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -72,7 +74,9 @@ async def create_vehicle(
return VehicleResponse.model_validate(vehicle) return VehicleResponse.model_validate(vehicle)
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK) @router.get(
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
)
async def get_vehicle( async def get_vehicle(
vehicle_id: uuid.UUID, vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -83,12 +87,16 @@ async def get_vehicle(
if vehicle is None: if vehicle is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}}, detail={
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
},
) )
return VehicleResponse.model_validate(vehicle) return VehicleResponse.model_validate(vehicle)
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK) @router.put(
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
)
async def update_vehicle( async def update_vehicle(
vehicle_id: uuid.UUID, vehicle_id: uuid.UUID,
body: VehicleUpdate, body: VehicleUpdate,
@@ -112,12 +120,16 @@ async def update_vehicle(
if vehicle is None: if vehicle is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}}, detail={
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
},
) )
return VehicleResponse.model_validate(vehicle) return VehicleResponse.model_validate(vehicle)
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK) @router.delete(
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
)
async def delete_vehicle( async def delete_vehicle(
vehicle_id: uuid.UUID, vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -128,7 +140,9 @@ async def delete_vehicle(
if vehicle is None: if vehicle is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}}, detail={
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
},
) )
return VehicleResponse.model_validate(vehicle) return VehicleResponse.model_validate(vehicle)
@@ -148,7 +162,9 @@ async def push_to_mobile_de(
if vehicle is None: if vehicle is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}}, detail={
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
},
) )
listing = await mobilede_service.push_listing(db, vehicle) listing = await mobilede_service.push_listing(db, vehicle)
@@ -176,7 +192,9 @@ async def get_mobile_de_status(
if vehicle is None: if vehicle is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}}, detail={
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
},
) )
listing = await mobilede_service.get_listing_status(db, vehicle_id) listing = await mobilede_service.get_listing_status(db, vehicle_id)
+1
View File
@@ -24,6 +24,7 @@ class ContactPersonBase(BaseModel):
class ContactPersonCreate(ContactPersonBase): class ContactPersonCreate(ContactPersonBase):
"""POST /api/v1/contacts/:id/persons request body.""" """POST /api/v1/contacts/:id/persons request body."""
pass pass
+15 -5
View File
@@ -10,14 +10,18 @@ class ChatRequest(BaseModel):
"""Request body for POST /copilot/chat.""" """Request body for POST /copilot/chat."""
message: str = Field(..., min_length=1, description="User message to the Copilot") message: str = Field(..., min_length=1, description="User message to the Copilot")
session_id: Optional[str] = Field(None, description="Existing session UUID to continue") session_id: Optional[str] = Field(
None, description="Existing session UUID to continue"
)
class ActionItem(BaseModel): class ActionItem(BaseModel):
"""A single action proposed by the Copilot.""" """A single action proposed by the Copilot."""
type: str = Field(..., description="Action type, e.g. search_vehicles") type: str = Field(..., description="Action type, e.g. search_vehicles")
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters") params: dict[str, Any] = Field(
default_factory=dict, description="Action parameters"
)
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
@@ -26,7 +30,9 @@ class ChatResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
response: str = Field(..., description="Assistant text response") response: str = Field(..., description="Assistant text response")
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions") actions: list[ActionItem] = Field(
default_factory=list, description="Proposed actions"
)
session_id: str = Field(..., description="Session UUID") session_id: str = Field(..., description="Session UUID")
message_id: str = Field(..., description="Assistant message UUID") message_id: str = Field(..., description="Assistant message UUID")
@@ -35,7 +41,9 @@ class ActionRequest(BaseModel):
"""Request body for POST /copilot/action — user confirms an action.""" """Request body for POST /copilot/action — user confirms an action."""
action: str = Field(..., min_length=1, description="Action type to execute") action: str = Field(..., min_length=1, description="Action type to execute")
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters") params: dict[str, Any] = Field(
default_factory=dict, description="Action parameters"
)
session_id: Optional[str] = Field(None, description="Session context") session_id: Optional[str] = Field(None, description="Session context")
@@ -89,6 +97,8 @@ class VoiceResponse(BaseModel):
transcription: str = Field(..., description="Transcribed text") transcription: str = Field(..., description="Transcribed text")
response: str = Field(..., description="Assistant text response") response: str = Field(..., description="Assistant text response")
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions") actions: list[ActionItem] = Field(
default_factory=list, description="Proposed actions"
)
session_id: str = Field(..., description="Session UUID") session_id: str = Field(..., description="Session UUID")
message_id: str = Field(..., description="Assistant message UUID") message_id: str = Field(..., description="Assistant message UUID")
+10
View File
@@ -9,12 +9,14 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
"""POST /api/v1/auth/login request body.""" """POST /api/v1/auth/login request body."""
email: EmailStr email: EmailStr
password: str = Field(..., min_length=1) password: str = Field(..., min_length=1)
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
"""JWT token pair returned after login or refresh.""" """JWT token pair returned after login or refresh."""
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
@@ -23,11 +25,13 @@ class TokenResponse(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
"""POST /api/v1/auth/refresh request body.""" """POST /api/v1/auth/refresh request body."""
refresh_token: str refresh_token: str
class UserBase(BaseModel): class UserBase(BaseModel):
"""Base user fields shared across schemas.""" """Base user fields shared across schemas."""
email: EmailStr email: EmailStr
full_name: str = Field(..., min_length=1, max_length=200) full_name: str = Field(..., min_length=1, max_length=200)
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer" role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
@@ -36,11 +40,13 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
"""POST /api/v1/users request body.""" """POST /api/v1/users request body."""
password: str = Field(..., min_length=8, max_length=128) password: str = Field(..., min_length=8, max_length=128)
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
"""PUT /api/v1/users/:id request body (all fields optional).""" """PUT /api/v1/users/:id request body (all fields optional)."""
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, min_length=1, max_length=200) full_name: Optional[str] = Field(None, min_length=1, max_length=200)
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
@@ -50,6 +56,7 @@ class UserUpdate(BaseModel):
class UserResponse(BaseModel): class UserResponse(BaseModel):
"""User response schema (never exposes password_hash).""" """User response schema (never exposes password_hash)."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
@@ -64,6 +71,7 @@ class UserResponse(BaseModel):
class UserListResponse(BaseModel): class UserListResponse(BaseModel):
"""Paginated user list response.""" """Paginated user list response."""
items: list[UserResponse] items: list[UserResponse]
total: int total: int
page: int page: int
@@ -72,9 +80,11 @@ class UserListResponse(BaseModel):
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
"""Standard error response format.""" """Standard error response format."""
error: dict error: dict
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
"""Health check response.""" """Health check response."""
status: str = "ok" status: str = "ok"
+1
View File
@@ -50,6 +50,7 @@ class VehicleBase(BaseModel):
class VehicleCreate(VehicleBase): class VehicleCreate(VehicleBase):
"""POST /api/v1/vehicles request body.""" """POST /api/v1/vehicles request body."""
pass pass
+5 -5
View File
@@ -39,7 +39,9 @@ async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]: async def authenticate_user(
db: AsyncSession, email: str, password: str
) -> Optional[User]:
"""Authenticate a user by email and password.""" """Authenticate a user by email and password."""
user = await get_user_by_email(db, email) user = await get_user_by_email(db, email)
if user is None: if user is None:
@@ -67,6 +69,7 @@ def generate_token_pair(user: User) -> dict:
lang=user.language, lang=user.language,
) )
from app.config import settings from app.config import settings
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@@ -135,10 +138,7 @@ async def list_users(
offset = (page - 1) * page_size offset = (page - 1) * page_size
result = await db.execute( result = await db.execute(
select(User) select(User).order_by(User.created_at.desc()).offset(offset).limit(page_size)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
) )
users = list(result.scalars().all()) users = list(result.scalars().all())
return users, total return users, total
+3 -9
View File
@@ -137,24 +137,18 @@ async def list_contacts(
return contacts, total return contacts, total
async def get_contact_by_id( async def get_contact_by_id(db: AsyncSession, contact_id: uuid.UUID) -> Contact | None:
db: AsyncSession, contact_id: uuid.UUID
) -> Contact | None:
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons.""" """Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
stmt = ( stmt = (
select(Contact) select(Contact)
.options(selectinload(Contact.contact_persons)) .options(selectinload(Contact.contact_persons))
.where( .where(and_(Contact.id == contact_id, Contact.deleted_at.is_(None)))
and_(Contact.id == contact_id, Contact.deleted_at.is_(None))
)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create_contact( async def create_contact(db: AsyncSession, data: dict[str, Any]) -> Contact:
db: AsyncSession, data: dict[str, Any]
) -> Contact:
"""Create a new contact with optional nested contact persons. """Create a new contact with optional nested contact persons.
The data dict may contain a 'contact_persons' list of dicts. The data dict may contain a 'contact_persons' list of dicts.
+7 -3
View File
@@ -74,10 +74,12 @@ def _parse_ai_response(raw_content: str) -> dict[str, Any]:
valid_actions = [] valid_actions = []
for action in actions: for action in actions:
if isinstance(action, dict) and "type" in action: if isinstance(action, dict) and "type" in action:
valid_actions.append({ valid_actions.append(
{
"type": action["type"], "type": action["type"],
"params": action.get("params", {}), "params": action.get("params", {}),
}) }
)
return {"response": response_text, "actions": valid_actions} return {"response": response_text, "actions": valid_actions}
@@ -172,7 +174,9 @@ async def chat(
6. Save the assistant message 6. Save the assistant message
7. Return response with actions and IDs 7. Return response with actions and IDs
""" """
session = await _get_or_create_session(db, user_id, session_id, first_message=message) session = await _get_or_create_session(
db, user_id, session_id, first_message=message
)
# Save user message # Save user message
user_msg = CopilotChat( user_msg = CopilotChat(
+4 -4
View File
@@ -60,9 +60,7 @@ async def create_export(
csv_content = generate_datev_csv(sales) csv_content = generate_datev_csv(sales)
# Calculate total amount # Calculate total amount
total_amount = sum( total_amount = sum((s.sale_price or Decimal("0")) for s in sales)
(s.sale_price or Decimal("0")) for s in sales
)
# Save CSV file # Save CSV file
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
@@ -111,7 +109,9 @@ async def list_exports(
return exports, total return exports, total
async def get_export_csv(db: AsyncSession, export_id: uuid.UUID) -> tuple[str, bytes] | None: async def get_export_csv(
db: AsyncSession, export_id: uuid.UUID
) -> tuple[str, bytes] | None:
"""Get the CSV content for a DATEV export. """Get the CSV content for a DATEV export.
Args: Args:
+4 -4
View File
@@ -98,7 +98,9 @@ async def upload_file(
""" """
# Validate MIME type # Validate MIME type
if not validate_mime_type(mime_type, original_filename): if not validate_mime_type(mime_type, original_filename):
raise ValueError(f"Unsupported MIME type: {mime_type} for file: {original_filename}") raise ValueError(
f"Unsupported MIME type: {mime_type} for file: {original_filename}"
)
# Validate file size (20MB limit for uploads) # Validate file size (20MB limit for uploads)
file_size = len(file_content) file_size = len(file_content)
@@ -173,9 +175,7 @@ async def list_files(
Returns (files, total_count). Returns (files, total_count).
""" """
# Count total files for this vehicle # Count total files for this vehicle
count_stmt = select(func.count(File.id)).where( count_stmt = select(func.count(File.id)).where(File.vehicle_id == vehicle_id)
File.vehicle_id == vehicle_id
)
count_result = await db.execute(count_stmt) count_result = await db.execute(count_stmt)
total = count_result.scalar_one() total = count_result.scalar_one()
+3 -8
View File
@@ -48,9 +48,7 @@ def _get_status_url(listing_id: str) -> str:
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status" return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
async def push_listing( async def push_listing(db: AsyncSession, vehicle: Vehicle) -> MobileDeListing:
db: AsyncSession, vehicle: Vehicle
) -> MobileDeListing:
"""Push a vehicle listing to mobile.de. """Push a vehicle listing to mobile.de.
Creates a MobileDeListing record with status 'pending', Creates a MobileDeListing record with status 'pending',
@@ -150,9 +148,7 @@ async def update_listing(
return listing return listing
async def delete_listing( async def delete_listing(db: AsyncSession, listing: MobileDeListing) -> MobileDeListing:
db: AsyncSession, listing: MobileDeListing
) -> MobileDeListing:
"""Delete a listing from mobile.de. """Delete a listing from mobile.de.
Sends DELETE /api/seller/listings/{id}. Sends DELETE /api/seller/listings/{id}.
@@ -228,8 +224,7 @@ async def retry_failed_listing(
if retry_count >= MAX_RETRIES: if retry_count >= MAX_RETRIES:
listing.sync_status = "fehler" listing.sync_status = "fehler"
listing.error_log = ( listing.error_log = (
f"Max retries ({MAX_RETRIES}) exceeded. " f"Max retries ({MAX_RETRIES}) exceeded. Last error: {listing.error_log}"
f"Last error: {listing.error_log}"
) )
await db.flush() await db.flush()
await db.refresh(listing) await db.refresh(listing)
+6 -4
View File
@@ -46,12 +46,12 @@ async def upload_file(
Validates MIME type and file size before saving. Validates MIME type and file size before saving.
""" """
if not validate_mime_type(mime_type): if not validate_mime_type(mime_type):
raise ValueError(f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}") raise ValueError(
f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}"
)
if not validate_file_size(len(file_bytes)): if not validate_file_size(len(file_bytes)):
raise ValueError( raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB"
)
# Ensure upload directory exists # Ensure upload directory exists
upload_dir = settings.UPLOAD_DIR upload_dir = settings.UPLOAD_DIR
@@ -173,6 +173,7 @@ async def apply_to_vehicle(
if ocr_field == "first_registration" and isinstance(value, str): if ocr_field == "first_registration" and isinstance(value, str):
try: try:
from datetime import datetime as dt from datetime import datetime as dt
parsed = dt.strptime(value, "%d.%m.%Y").date() parsed = dt.strptime(value, "%d.%m.%Y").date()
setattr(vehicle, vehicle_field, parsed) setattr(vehicle, vehicle_field, parsed)
updated_fields.append(vehicle_field) updated_fields.append(vehicle_field)
@@ -180,6 +181,7 @@ async def apply_to_vehicle(
except ValueError: except ValueError:
try: try:
from datetime import date from datetime import date
parsed = date.fromisoformat(value) parsed = date.fromisoformat(value)
setattr(vehicle, vehicle_field, parsed) setattr(vehicle, vehicle_field, parsed)
updated_fields.append(vehicle_field) updated_fields.append(vehicle_field)
@@ -82,7 +82,9 @@ async def compare_prices(
# Calculate average price # Calculate average price
if listings: if listings:
average_price = round(sum(listing.price for listing in listings) / len(listings), 2) average_price = round(
sum(listing.price for listing in listings) / len(listings), 2
)
else: else:
average_price = None average_price = None
+11 -3
View File
@@ -80,7 +80,9 @@ def generate_retouch_prompt(vehicle_info: dict[str, Any] | None = None) -> str:
if make and model: if make and model:
parts.append(f"The vehicle is a {make} {model}.") parts.append(f"The vehicle is a {make} {model}.")
if color: if color:
parts.append(f"The vehicle color is {color}; ensure it looks accurate and rich.") parts.append(
f"The vehicle color is {color}; ensure it looks accurate and rich."
)
return " ".join(parts) return " ".join(parts)
return base_prompt return base_prompt
@@ -98,7 +100,9 @@ async def upload_retouch_file(
Raises ValueError for invalid MIME type or file size. Raises ValueError for invalid MIME type or file size.
""" """
if not validate_mime_type(mime_type): if not validate_mime_type(mime_type):
raise ValueError(f"Invalid MIME type: {mime_type}. Only image/* types are allowed.") raise ValueError(
f"Invalid MIME type: {mime_type}. Only image/* types are allowed."
)
if not validate_file_size(len(file_bytes)): if not validate_file_size(len(file_bytes)):
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB") raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
@@ -151,7 +155,11 @@ async def list_results(
total = total_result.scalar_one() total = total_result.scalar_one()
offset = (page - 1) * page_size offset = (page - 1) * page_size
data_stmt = data_stmt.order_by(RetouchResult.created_at.desc()).offset(offset).limit(page_size) data_stmt = (
data_stmt.order_by(RetouchResult.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(data_stmt) result = await db.execute(data_stmt)
items = list(result.scalars().all()) items = list(result.scalars().all())
+4 -12
View File
@@ -131,9 +131,7 @@ async def list_vehicles(
return vehicles, total return vehicles, total
async def get_vehicle_by_id( async def get_vehicle_by_id(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle | None:
db: AsyncSession, vehicle_id: uuid.UUID
) -> Vehicle | None:
"""Get a single vehicle by ID, excluding soft-deleted.""" """Get a single vehicle by ID, excluding soft-deleted."""
stmt = select(Vehicle).where( stmt = select(Vehicle).where(
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None)) and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
@@ -142,20 +140,14 @@ async def get_vehicle_by_id(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_vehicle_by_fin( async def get_vehicle_by_fin(db: AsyncSession, fin: str) -> Vehicle | None:
db: AsyncSession, fin: str
) -> Vehicle | None:
"""Get a single vehicle by FIN, excluding soft-deleted.""" """Get a single vehicle by FIN, excluding soft-deleted."""
stmt = select(Vehicle).where( stmt = select(Vehicle).where(and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None)))
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
)
result = await db.execute(stmt) result = await db.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create_vehicle( async def create_vehicle(db: AsyncSession, data: dict[str, Any]) -> Vehicle:
db: AsyncSession, data: dict[str, Any]
) -> Vehicle:
"""Create a new vehicle. """Create a new vehicle.
Raises ValueError if FIN already exists. Raises ValueError if FIN already exists.
+8 -3
View File
@@ -11,7 +11,6 @@ from decimal import Decimal
from typing import Any from typing import Any
# HTML template for the sales contract # HTML template for the sales contract
_CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html> _CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="de"> <html lang="de">
@@ -191,7 +190,11 @@ def build_contract_html(sale: Any) -> str:
# Determine GwG clause # Determine GwG clause
gwg_section = "" gwg_section = ""
if sale.is_gwg and sale.sale_price is not None and sale.sale_price <= Decimal("800"): if (
sale.is_gwg
and sale.sale_price is not None
and sale.sale_price <= Decimal("800")
):
gwg_section = _GWG_CLAUSE_HTML gwg_section = _GWG_CLAUSE_HTML
html = _CONTRACT_HTML_TEMPLATE.format( html = _CONTRACT_HTML_TEMPLATE.format(
@@ -207,7 +210,9 @@ def build_contract_html(sale: Any) -> str:
vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A", vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A",
vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A", vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A",
vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A", vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A",
first_registration=_format_date(getattr(vehicle, "first_registration", None)) if vehicle else "N/A", first_registration=_format_date(getattr(vehicle, "first_registration", None))
if vehicle
else "N/A",
mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A", mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A",
power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A", power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A",
power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A", power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A",
+15 -5
View File
@@ -73,7 +73,9 @@ async def _search_contacts(db: AsyncSession, params: dict[str, Any]) -> dict[str
} }
async def _get_sale_overview(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]: async def _get_sale_overview(
db: AsyncSession, params: dict[str, Any]
) -> dict[str, Any]:
"""Get an overview of sales, optionally filtered by status or date range.""" """Get an overview of sales, optionally filtered by status or date range."""
from app.models.sale import Sale from app.models.sale import Sale
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -93,7 +95,9 @@ async def _get_sale_overview(db: AsyncSession, params: dict[str, Any]) -> dict[s
total = total_result.scalar_one() total = total_result.scalar_one()
offset = (page - 1) * page_size offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc()) data_stmt = (
data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc())
)
result = await db.execute(data_stmt) result = await db.execute(data_stmt)
sales = list(result.scalars().all()) sales = list(result.scalars().all())
@@ -110,7 +114,9 @@ async def _create_vehicle(db: AsyncSession, params: dict[str, Any]) -> dict[str,
required = ["make", "model", "fin", "price", "vehicle_type"] required = ["make", "model", "fin", "price", "vehicle_type"]
missing = [f for f in required if not params.get(f)] missing = [f for f in required if not params.get(f)]
if missing: if missing:
raise ValueError(f"Missing required fields for create_vehicle: {', '.join(missing)}") raise ValueError(
f"Missing required fields for create_vehicle: {', '.join(missing)}"
)
vehicle = await vehicle_service.create_vehicle(db, params) vehicle = await vehicle_service.create_vehicle(db, params)
return vehicle.to_dict() return vehicle.to_dict()
@@ -121,7 +127,9 @@ async def _create_contact(db: AsyncSession, params: dict[str, Any]) -> dict[str,
required = ["company_name", "role"] required = ["company_name", "role"]
missing = [f for f in required if not params.get(f)] missing = [f for f in required if not params.get(f)]
if missing: if missing:
raise ValueError(f"Missing required fields for create_contact: {', '.join(missing)}") raise ValueError(
f"Missing required fields for create_contact: {', '.join(missing)}"
)
if "address_country" not in params: if "address_country" not in params:
params["address_country"] = "DE" params["address_country"] = "DE"
@@ -193,7 +201,9 @@ def get_available_actions() -> list[dict[str, Any]]:
] ]
async def execute_action(db: AsyncSession, action_type: str, params: dict[str, Any]) -> Any: async def execute_action(
db: AsyncSession, action_type: str, params: dict[str, Any]
) -> Any:
"""Execute a registered action by type. """Execute a registered action by type.
Raises ValueError if the action type is not registered. Raises ValueError if the action type is not registered.
+1 -3
View File
@@ -109,9 +109,7 @@ def map_fields(vehicle: Vehicle) -> dict[str, Any]:
} }
if vehicle.first_registration is not None: if vehicle.first_registration is not None:
ad["firstRegistration"] = _format_first_registration( ad["firstRegistration"] = _format_first_registration(vehicle.first_registration)
vehicle.first_registration
)
if mileage is not None: if mileage is not None:
ad["mileage"] = mileage ad["mileage"] = mileage
+10 -2
View File
@@ -98,10 +98,18 @@ def _parse_response(raw_content: str) -> dict[str, Any]:
data = json.loads(text[start : end + 1]) data = json.loads(text[start : end + 1])
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error("Failed to parse OpenRouter response: %s", text[:200]) logger.error("Failed to parse OpenRouter response: %s", text[:200])
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content} return {
"structured_data": {},
"confidence_score": 0.0,
"raw_text": raw_content,
}
else: else:
logger.error("No JSON found in OpenRouter response: %s", text[:200]) logger.error("No JSON found in OpenRouter response: %s", text[:200])
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content} return {
"structured_data": {},
"confidence_score": 0.0,
"raw_text": raw_content,
}
# Extract confidence score (may be inside or outside the data) # Extract confidence score (may be inside or outside the data)
confidence = data.pop("confidence_score", None) confidence = data.pop("confidence_score", None)
+6 -2
View File
@@ -71,13 +71,17 @@ def generate_thumbnail(
background = Image.new("RGB", img.size, (255, 255, 255)) background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P": if img.mode == "P":
img = img.convert("RGBA") img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None) background.paste(
img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None
)
img = background img = background
elif img.mode != "RGB": elif img.mode != "RGB":
img = img.convert("RGB") img = img.convert("RGB")
# Use ImageOps.fit for a centered crop to exact thumbnail size # Use ImageOps.fit for a centered crop to exact thumbnail size
thumbnail = ImageOps.fit(img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS) thumbnail = ImageOps.fit(
img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS
)
thumbnail.save(thumbnail_path, quality=85, optimize=True) thumbnail.save(thumbnail_path, quality=85, optimize=True)
logger.info("Thumbnail generated: %s", thumbnail_path) logger.info("Thumbnail generated: %s", thumbnail_path)
+27 -3
View File
@@ -38,9 +38,33 @@ _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). # Set of supported EU country codes (ISO 3166-1 alpha-2).
_EU_COUNTRY_CODES: set[str] = { _EU_COUNTRY_CODES: set[str] = {
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "AT",
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "BE",
"SE", "SI", "SK", "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",
} }
+3 -1
View File
@@ -5,7 +5,6 @@ from typing import AsyncGenerator
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -26,6 +25,7 @@ TEST_DATABASE_URL = (
def event_loop(): def event_loop():
"""Create a fresh event loop per test for asyncpg compatibility.""" """Create a fresh event loop per test for asyncpg compatibility."""
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
yield loop yield loop
loop.close() loop.close()
@@ -117,6 +117,7 @@ async def inactive_user(db_session: AsyncSession) -> User:
def _get_test_token(user: User) -> str: def _get_test_token(user: User) -> str:
"""Generate an access token for a test user.""" """Generate an access token for a test user."""
from app.utils.jwt import create_access_token from app.utils.jwt import create_access_token
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role) role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
return create_access_token( return create_access_token(
user_id=str(user.id), user_id=str(user.id),
@@ -141,6 +142,7 @@ async def verkaeufer_token(verkaeufer_user: User) -> str:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(test_session_factory) -> AsyncGenerator[AsyncClient, None]: async def client(test_session_factory) -> AsyncGenerator[AsyncClient, None]:
"""Async HTTP test client with DB session override.""" """Async HTTP test client with DB session override."""
async def _override_get_db(): async def _override_get_db():
async with test_session_factory() as session: async with test_session_factory() as session:
try: try:
+43 -17
View File
@@ -10,10 +10,13 @@ from app.utils.jwt import create_access_token, create_refresh_token
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_valid_credentials(client: AsyncClient, admin_user: User): async def test_login_valid_credentials(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/login with valid credentials returns 200 + JWT.""" """POST /api/v1/auth/login with valid credentials returns 200 + JWT."""
response = await client.post("/api/v1/auth/login", json={ response = await client.post(
"/api/v1/auth/login",
json={
"email": "admin@test.com", "email": "admin@test.com",
"password": "Admin12345!", "password": "Admin12345!",
}) },
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "access_token" in data assert "access_token" in data
@@ -25,10 +28,13 @@ async def test_login_valid_credentials(client: AsyncClient, admin_user: User):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_invalid_password(client: AsyncClient, admin_user: User): async def test_login_invalid_password(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/login with wrong password returns 401.""" """POST /api/v1/auth/login with wrong password returns 401."""
response = await client.post("/api/v1/auth/login", json={ response = await client.post(
"/api/v1/auth/login",
json={
"email": "admin@test.com", "email": "admin@test.com",
"password": "WrongPassword!", "password": "WrongPassword!",
}) },
)
assert response.status_code == 401 assert response.status_code == 401
data = response.json() data = response.json()
assert "error" in data["detail"] assert "error" in data["detail"]
@@ -37,30 +43,39 @@ async def test_login_invalid_password(client: AsyncClient, admin_user: User):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_nonexistent_user(client: AsyncClient): async def test_login_nonexistent_user(client: AsyncClient):
"""POST /api/v1/auth/login with unknown email returns 401.""" """POST /api/v1/auth/login with unknown email returns 401."""
response = await client.post("/api/v1/auth/login", json={ response = await client.post(
"/api/v1/auth/login",
json={
"email": "nobody@test.com", "email": "nobody@test.com",
"password": "SomePassword!", "password": "SomePassword!",
}) },
)
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_inactive_user(client: AsyncClient, inactive_user: User): async def test_login_inactive_user(client: AsyncClient, inactive_user: User):
"""POST /api/v1/auth/login with deactivated account returns 401.""" """POST /api/v1/auth/login with deactivated account returns 401."""
response = await client.post("/api/v1/auth/login", json={ response = await client.post(
"/api/v1/auth/login",
json={
"email": "inactive@test.com", "email": "inactive@test.com",
"password": "Inactive123!", "password": "Inactive123!",
}) },
)
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_invalid_email_format(client: AsyncClient): async def test_login_invalid_email_format(client: AsyncClient):
"""POST /api/v1/auth/login with invalid email format returns 422.""" """POST /api/v1/auth/login with invalid email format returns 422."""
response = await client.post("/api/v1/auth/login", json={ response = await client.post(
"/api/v1/auth/login",
json={
"email": "not-an-email", "email": "not-an-email",
"password": "SomePassword!", "password": "SomePassword!",
}) },
)
assert response.status_code == 422 assert response.status_code == 422
@@ -73,9 +88,12 @@ async def test_refresh_valid_token(client: AsyncClient, admin_user: User):
email=admin_user.email, email=admin_user.email,
lang=admin_user.language, lang=admin_user.language,
) )
response = await client.post("/api/v1/auth/refresh", json={ response = await client.post(
"/api/v1/auth/refresh",
json={
"refresh_token": refresh_token, "refresh_token": refresh_token,
}) },
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "access_token" in data assert "access_token" in data
@@ -86,9 +104,12 @@ async def test_refresh_valid_token(client: AsyncClient, admin_user: User):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refresh_invalid_token(client: AsyncClient): async def test_refresh_invalid_token(client: AsyncClient):
"""POST /api/v1/auth/refresh with invalid token returns 401.""" """POST /api/v1/auth/refresh with invalid token returns 401."""
response = await client.post("/api/v1/auth/refresh", json={ response = await client.post(
"/api/v1/auth/refresh",
json={
"refresh_token": "invalid.token.here", "refresh_token": "invalid.token.here",
}) },
)
assert response.status_code == 401 assert response.status_code == 401
@@ -101,14 +122,19 @@ async def test_refresh_access_token_rejected(client: AsyncClient, admin_user: Us
email=admin_user.email, email=admin_user.email,
lang=admin_user.language, lang=admin_user.language,
) )
response = await client.post("/api/v1/auth/refresh", json={ response = await client.post(
"/api/v1/auth/refresh",
json={
"refresh_token": access_token, "refresh_token": access_token,
}) },
)
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_with_valid_token(client: AsyncClient, admin_user: User, admin_token: str): async def test_get_me_with_valid_token(
client: AsyncClient, admin_user: User, admin_token: str
):
"""GET /api/v1/auth/me with valid JWT returns 200 + user object.""" """GET /api/v1/auth/me with valid JWT returns 200 + user object."""
response = await client.get( response = await client.get(
"/api/v1/auth/me", "/api/v1/auth/me",
+1
View File
@@ -34,6 +34,7 @@ TEST_DATABASE_URL = (
@pytest.fixture @pytest.fixture
def event_loop(): def event_loop():
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
yield loop yield loop
loop.close() loop.close()
+169 -55
View File
@@ -1,15 +1,11 @@
"""Tests for contact CRUD, search, filter, and contact person endpoints.""" """Tests for contact CRUD, search, filter, and contact person endpoints."""
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.database import Base, get_db from app.models.contact import Contact
from app.main import app
from app.models.contact import Contact, ContactPerson
from app.utils.ust_validation import validate_vat_id from app.utils.ust_validation import validate_vat_id
@@ -115,7 +111,9 @@ class TestContactList:
"""GET /api/v1/contacts tests.""" """GET /api/v1/contacts tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_contacts_returns_200_with_pagination(self, admin_client, created_contact): async def test_list_contacts_returns_200_with_pagination(
self, admin_client, created_contact
):
"""GET /api/v1/contacts returns 200 with paginated list.""" """GET /api/v1/contacts returns 200 with paginated list."""
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=20") response = await admin_client.get("/api/v1/contacts/?page=1&page_size=20")
assert response.status_code == 200 assert response.status_code == 200
@@ -136,7 +134,9 @@ class TestContactList:
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_contact_data) resp1 = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
assert resp1.status_code == 201 assert resp1.status_code == 201
# Create a beide contact # Create a beide contact
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data) resp2 = await admin_client.post(
"/api/v1/contacts/", json=sample_beide_contact_data
)
assert resp2.status_code == 201 assert resp2.status_code == 201
response = await admin_client.get("/api/v1/contacts/?role=kaeufer") response = await admin_client.get("/api/v1/contacts/?role=kaeufer")
@@ -152,9 +152,13 @@ class TestContactList:
self, admin_client, sample_eu_contact_data, sample_beide_contact_data self, admin_client, sample_eu_contact_data, sample_beide_contact_data
): ):
"""GET /api/v1/contacts?role=verkaeufer returns verkaeufer + beide contacts.""" """GET /api/v1/contacts?role=verkaeufer returns verkaeufer + beide contacts."""
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data) resp1 = await admin_client.post(
"/api/v1/contacts/", json=sample_eu_contact_data
)
assert resp1.status_code == 201 assert resp1.status_code == 201
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data) resp2 = await admin_client.post(
"/api/v1/contacts/", json=sample_beide_contact_data
)
assert resp2.status_code == 201 assert resp2.status_code == 201
response = await admin_client.get("/api/v1/contacts/?role=verkaeufer") response = await admin_client.get("/api/v1/contacts/?role=verkaeufer")
@@ -194,7 +198,9 @@ class TestContactList:
assert item["address_country"] == "DE" assert item["address_country"] == "DE"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_contacts_search_by_company_name(self, admin_client, created_contact): async def test_list_contacts_search_by_company_name(
self, admin_client, created_contact
):
"""GET /api/v1/contacts?search=mueller returns matching contacts.""" """GET /api/v1/contacts?search=mueller returns matching contacts."""
response = await admin_client.get("/api/v1/contacts/?search=mueller") response = await admin_client.get("/api/v1/contacts/?search=mueller")
assert response.status_code == 200 assert response.status_code == 200
@@ -220,7 +226,9 @@ class TestContactList:
assert len(data["items"]) == 0 assert len(data["items"]) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_contacts_sort_by_company_name(self, admin_client, sample_contact_data, sample_eu_contact_data): 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.""" """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_contact_data)
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data) await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
@@ -251,7 +259,9 @@ class TestContactDetail:
"""GET /api/v1/contacts/:id tests.""" """GET /api/v1/contacts/:id tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_contact_returns_200_with_detail(self, admin_client, created_contact): async def test_get_contact_returns_200_with_detail(
self, admin_client, created_contact
):
"""GET /api/v1/contacts/:id returns 200 with contact detail.""" """GET /api/v1/contacts/:id returns 200 with contact detail."""
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}") response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
assert response.status_code == 200 assert response.status_code == 200
@@ -271,9 +281,13 @@ class TestContactDetail:
assert data["detail"]["error"]["code"] == "CONTACT_NOT_FOUND" assert data["detail"]["error"]["code"] == "CONTACT_NOT_FOUND"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_contact_after_soft_delete_returns_404(self, admin_client, created_contact): 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.""" """GET /api/v1/contacts/:id after soft-delete returns 404."""
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}") del_resp = await admin_client.delete(
f"/api/v1/contacts/{created_contact['id']}"
)
assert del_resp.status_code == 200 assert del_resp.status_code == 200
get_resp = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}") get_resp = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
assert get_resp.status_code == 404 assert get_resp.status_code == 404
@@ -285,7 +299,9 @@ class TestContactCreate:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_returns_201(self, admin_client, sample_contact_data): async def test_create_contact_returns_201(self, admin_client, sample_contact_data):
"""POST /api/v1/contacts with valid data returns 201.""" """POST /api/v1/contacts with valid data returns 201."""
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data) response = await admin_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["company_name"] == sample_contact_data["company_name"] assert data["company_name"] == sample_contact_data["company_name"]
@@ -294,21 +310,27 @@ class TestContactCreate:
assert data["id"] is not None assert data["id"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_with_invalid_vat_id_returns_422(self, admin_client, sample_contact_data): 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.""" """POST /api/v1/contacts with invalid VAT ID format returns 422."""
data = {**sample_contact_data, "vat_id": "INVALID123"} data = {**sample_contact_data, "vat_id": "INVALID123"}
response = await admin_client.post("/api/v1/contacts/", json=data) response = await admin_client.post("/api/v1/contacts/", json=data)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_with_de_vat_too_short_returns_422(self, admin_client, sample_contact_data): 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.""" """POST /api/v1/contacts with too-short DE VAT ID returns 422."""
data = {**sample_contact_data, "vat_id": "DE12345678"} data = {**sample_contact_data, "vat_id": "DE12345678"}
response = await admin_client.post("/api/v1/contacts/", json=data) response = await admin_client.post("/api/v1/contacts/", json=data)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_with_no_vat_id_returns_201(self, admin_client, sample_contact_data): 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.""" """POST /api/v1/contacts without VAT ID returns 201."""
data = {**sample_contact_data} data = {**sample_contact_data}
data.pop("vat_id") data.pop("vat_id")
@@ -317,7 +339,9 @@ class TestContactCreate:
assert response.json()["vat_id"] is None assert response.json()["vat_id"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_with_contact_persons(self, admin_client, sample_contact_data): async def test_create_contact_with_contact_persons(
self, admin_client, sample_contact_data
):
"""POST /api/v1/contacts with nested contact persons returns 201.""" """POST /api/v1/contacts with nested contact persons returns 201."""
data = { data = {
**sample_contact_data, **sample_contact_data,
@@ -337,13 +361,19 @@ class TestContactCreate:
assert contact_data["contact_persons"][0]["name"] == "Hans Müller" assert contact_data["contact_persons"][0]["name"] == "Hans Müller"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_missing_required_fields_returns_422(self, admin_client): async def test_create_contact_missing_required_fields_returns_422(
self, admin_client
):
"""POST /api/v1/contacts with missing required fields returns 422.""" """POST /api/v1/contacts with missing required fields returns 422."""
response = await admin_client.post("/api/v1/contacts/", json={"address_country": "DE"}) response = await admin_client.post(
"/api/v1/contacts/", json={"address_country": "DE"}
)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_invalid_role_returns_422(self, admin_client, sample_contact_data): async def test_create_contact_invalid_role_returns_422(
self, admin_client, sample_contact_data
):
"""POST /api/v1/contacts with invalid role returns 422.""" """POST /api/v1/contacts with invalid role returns 422."""
data = {**sample_contact_data, "role": "invalid_role"} data = {**sample_contact_data, "role": "invalid_role"}
response = await admin_client.post("/api/v1/contacts/", json=data) response = await admin_client.post("/api/v1/contacts/", json=data)
@@ -376,7 +406,9 @@ class TestContactUpdate:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_contact_invalid_vat_id_returns_422(self, admin_client, created_contact): 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.""" """PUT /api/v1/contacts/:id with invalid VAT ID returns 422."""
response = await admin_client.put( response = await admin_client.put(
f"/api/v1/contacts/{created_contact['id']}", f"/api/v1/contacts/{created_contact['id']}",
@@ -385,7 +417,9 @@ class TestContactUpdate:
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_contact_no_fields_returns_400(self, admin_client, created_contact): async def test_update_contact_no_fields_returns_400(
self, admin_client, created_contact
):
"""PUT /api/v1/contacts/:id with no fields returns 400.""" """PUT /api/v1/contacts/:id with no fields returns 400."""
response = await admin_client.put( response = await admin_client.put(
f"/api/v1/contacts/{created_contact['id']}", f"/api/v1/contacts/{created_contact['id']}",
@@ -400,7 +434,9 @@ class TestContactDelete:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_contact_returns_200(self, admin_client, created_contact): async def test_delete_contact_returns_200(self, admin_client, created_contact):
"""DELETE /api/v1/contacts/:id returns 200 (soft delete).""" """DELETE /api/v1/contacts/:id returns 200 (soft delete)."""
response = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}") response = await admin_client.delete(
f"/api/v1/contacts/{created_contact['id']}"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["deleted_at"] is not None assert data["deleted_at"] is not None
@@ -415,7 +451,9 @@ class TestContactDelete:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deleted_contact_not_in_list(self, admin_client, created_contact): async def test_deleted_contact_not_in_list(self, admin_client, created_contact):
"""Soft-deleted contact does not appear in list.""" """Soft-deleted contact does not appear in list."""
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}") del_resp = await admin_client.delete(
f"/api/v1/contacts/{created_contact['id']}"
)
assert del_resp.status_code == 200 assert del_resp.status_code == 200
list_resp = await admin_client.get("/api/v1/contacts/") list_resp = await admin_client.get("/api/v1/contacts/")
assert list_resp.status_code == 200 assert list_resp.status_code == 200
@@ -445,7 +483,9 @@ class TestContactPersons:
assert data["id"] is not None assert data["id"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_contact_person_to_nonexistent_contact_returns_404(self, admin_client): async def test_add_contact_person_to_nonexistent_contact_returns_404(
self, admin_client
):
"""POST /api/v1/contacts/:nonexistent/persons returns 404.""" """POST /api/v1/contacts/:nonexistent/persons returns 404."""
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = await admin_client.post( response = await admin_client.post(
@@ -455,7 +495,9 @@ class TestContactPersons:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_contact_person_returns_204(self, admin_client, created_contact): async def test_remove_contact_person_returns_204(
self, admin_client, created_contact
):
"""DELETE /api/v1/contacts/:id/persons/:person_id returns 204.""" """DELETE /api/v1/contacts/:id/persons/:person_id returns 204."""
# First add a person # First add a person
add_resp = await admin_client.post( add_resp = await admin_client.post(
@@ -472,7 +514,9 @@ class TestContactPersons:
assert del_resp.status_code == 204 assert del_resp.status_code == 204
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_nonexistent_contact_person_returns_404(self, admin_client, created_contact): async def test_remove_nonexistent_contact_person_returns_404(
self, admin_client, created_contact
):
"""DELETE /api/v1/contacts/:id/persons/:nonexistent returns 404.""" """DELETE /api/v1/contacts/:id/persons/:nonexistent returns 404."""
fake_person_id = uuid.uuid4() fake_person_id = uuid.uuid4()
response = await admin_client.delete( response = await admin_client.delete(
@@ -506,25 +550,37 @@ class TestContactRBAC:
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_as_admin_returns_201(self, admin_client, sample_contact_data): async def test_create_contact_as_admin_returns_201(
self, admin_client, sample_contact_data
):
"""POST /api/v1/contacts as admin returns 201.""" """POST /api/v1/contacts as admin returns 201."""
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data) response = await admin_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
assert response.status_code == 201 assert response.status_code == 201
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_contact_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data): async def test_create_contact_as_verkaeufer_returns_201(
self, verkaeufer_client, sample_contact_data
):
"""POST /api/v1/contacts as verkaeufer returns 201.""" """POST /api/v1/contacts as verkaeufer returns 201."""
response = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data) response = await verkaeufer_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
assert response.status_code == 201 assert response.status_code == 201
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_contacts_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact): async def test_list_contacts_as_verkaeufer_returns_200(
self, verkaeufer_client, created_contact
):
"""GET /api/v1/contacts as verkaeufer returns 200 (read allowed).""" """GET /api/v1/contacts as verkaeufer returns 200 (read allowed)."""
response = await verkaeufer_client.get("/api/v1/contacts/") response = await verkaeufer_client.get("/api/v1/contacts/")
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_contact_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact): async def test_update_contact_as_verkaeufer_returns_200(
self, verkaeufer_client, created_contact
):
"""PUT /api/v1/contacts/:id as verkaeufer returns 200.""" """PUT /api/v1/contacts/:id as verkaeufer returns 200."""
response = await verkaeufer_client.put( response = await verkaeufer_client.put(
f"/api/v1/contacts/{created_contact['id']}", f"/api/v1/contacts/{created_contact['id']}",
@@ -533,18 +589,26 @@ class TestContactRBAC:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_contact_as_verkaeufer_returns_200(self, verkaeufer_client, sample_contact_data): async def test_delete_contact_as_verkaeufer_returns_200(
self, verkaeufer_client, sample_contact_data
):
"""DELETE /api/v1/contacts/:id as verkaeufer returns 200.""" """DELETE /api/v1/contacts/:id as verkaeufer returns 200."""
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data) resp = await verkaeufer_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
assert resp.status_code == 201 assert resp.status_code == 201
contact_id = resp.json()["id"] contact_id = resp.json()["id"]
del_resp = await verkaeufer_client.delete(f"/api/v1/contacts/{contact_id}") del_resp = await verkaeufer_client.delete(f"/api/v1/contacts/{contact_id}")
assert del_resp.status_code == 200 assert del_resp.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_person_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data): 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.""" """POST /api/v1/contacts/:id/persons as verkaeufer returns 201."""
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data) resp = await verkaeufer_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
assert resp.status_code == 201 assert resp.status_code == 201
contact_id = resp.json()["id"] contact_id = resp.json()["id"]
person_resp = await verkaeufer_client.post( person_resp = await verkaeufer_client.post(
@@ -554,9 +618,13 @@ class TestContactRBAC:
assert person_resp.status_code == 201 assert person_resp.status_code == 201
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_person_as_verkaeufer_returns_204(self, verkaeufer_client, sample_contact_data): 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.""" """DELETE /api/v1/contacts/:id/persons/:pid as verkaeufer returns 204."""
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data) resp = await verkaeufer_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
contact_id = resp.json()["id"] contact_id = resp.json()["id"]
person_resp = await verkaeufer_client.post( person_resp = await verkaeufer_client.post(
f"/api/v1/contacts/{contact_id}/persons", f"/api/v1/contacts/{contact_id}/persons",
@@ -569,7 +637,9 @@ class TestContactRBAC:
assert del_resp.status_code == 204 assert del_resp.status_code == 204
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client): async def test_update_nonexistent_contact_returns_404_as_verkaeufer(
self, verkaeufer_client
):
"""PUT /api/v1/contacts/:nonexistent as verkaeufer returns 404.""" """PUT /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = await verkaeufer_client.put( response = await verkaeufer_client.put(
@@ -579,14 +649,18 @@ class TestContactRBAC:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client): async def test_delete_nonexistent_contact_returns_404_as_verkaeufer(
self, verkaeufer_client
):
"""DELETE /api/v1/contacts/:nonexistent as verkaeufer returns 404.""" """DELETE /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = await verkaeufer_client.delete(f"/api/v1/contacts/{fake_id}") response = await verkaeufer_client.delete(f"/api/v1/contacts/{fake_id}")
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_person_to_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client): 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.""" """POST /api/v1/contacts/:nonexistent/persons as verkaeufer returns 404."""
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = await verkaeufer_client.post( response = await verkaeufer_client.post(
@@ -596,9 +670,13 @@ class TestContactRBAC:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_nonexistent_person_returns_404_as_verkaeufer(self, verkaeufer_client, sample_contact_data): 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.""" """DELETE /api/v1/contacts/:id/persons/:nonexistent as verkaeufer returns 404."""
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data) resp = await verkaeufer_client.post(
"/api/v1/contacts/", json=sample_contact_data
)
contact_id = resp.json()["id"] contact_id = resp.json()["id"]
fake_person_id = uuid.uuid4() fake_person_id = uuid.uuid4()
response = await verkaeufer_client.delete( response = await verkaeufer_client.delete(
@@ -614,6 +692,7 @@ class TestContactServiceDirect:
async def test_service_list_contacts_with_all_filters(self, db_session): async def test_service_list_contacts_with_all_filters(self, db_session):
"""Test list_contacts with all filter parameters.""" """Test list_contacts with all filter parameters."""
from app.services import contact_service from app.services import contact_service
contact1 = Contact( contact1 = Contact(
company_name="Alpha GmbH", company_name="Alpha GmbH",
address_city="Berlin", address_city="Berlin",
@@ -662,22 +741,29 @@ class TestContactServiceDirect:
) )
db_session.add(contact4) db_session.add(contact4)
await db_session.commit() await db_session.commit()
results, total = await contact_service.list_contacts(db_session, is_private=True) results, total = await contact_service.list_contacts(
db_session, is_private=True
)
assert total == 1 assert total == 1
# Test sort descending # Test sort descending
results, total = await contact_service.list_contacts(db_session, sort="-company_name") results, total = await contact_service.list_contacts(
db_session, sort="-company_name"
)
names = [r.company_name for r in results] names = [r.company_name for r in results]
assert names == sorted(names, reverse=True) assert names == sorted(names, reverse=True)
# Test invalid sort field falls back to created_at # Test invalid sort field falls back to created_at
results, total = await contact_service.list_contacts(db_session, sort="invalid_field") results, total = await contact_service.list_contacts(
db_session, sort="invalid_field"
)
assert total >= 4 assert total >= 4
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_service_get_contact_by_id_not_found(self, db_session): async def test_service_get_contact_by_id_not_found(self, db_session):
"""Test get_contact_by_id returns None for nonexistent ID.""" """Test get_contact_by_id returns None for nonexistent ID."""
from app.services import contact_service from app.services import contact_service
result = await contact_service.get_contact_by_id(db_session, uuid.uuid4()) result = await contact_service.get_contact_by_id(db_session, uuid.uuid4())
assert result is None assert result is None
@@ -685,6 +771,7 @@ class TestContactServiceDirect:
async def test_service_create_contact_with_persons(self, db_session): async def test_service_create_contact_with_persons(self, db_session):
"""Test create_contact with nested contact persons.""" """Test create_contact with nested contact persons."""
from app.services import contact_service from app.services import contact_service
data = { data = {
"company_name": "Test Service GmbH", "company_name": "Test Service GmbH",
"address_country": "DE", "address_country": "DE",
@@ -702,13 +789,17 @@ class TestContactServiceDirect:
async def test_service_update_contact_not_found(self, db_session): async def test_service_update_contact_not_found(self, db_session):
"""Test update_contact returns None for nonexistent ID.""" """Test update_contact returns None for nonexistent ID."""
from app.services import contact_service from app.services import contact_service
result = await contact_service.update_contact(db_session, uuid.uuid4(), {"company_name": "Test"})
result = await contact_service.update_contact(
db_session, uuid.uuid4(), {"company_name": "Test"}
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_service_soft_delete_contact_not_found(self, db_session): async def test_service_soft_delete_contact_not_found(self, db_session):
"""Test soft_delete_contact returns None for nonexistent ID.""" """Test soft_delete_contact returns None for nonexistent ID."""
from app.services import contact_service from app.services import contact_service
result = await contact_service.soft_delete_contact(db_session, uuid.uuid4()) result = await contact_service.soft_delete_contact(db_session, uuid.uuid4())
assert result is None assert result is None
@@ -716,20 +807,27 @@ class TestContactServiceDirect:
async def test_service_add_contact_person_not_found(self, db_session): async def test_service_add_contact_person_not_found(self, db_session):
"""Test add_contact_person returns None for nonexistent contact.""" """Test add_contact_person returns None for nonexistent contact."""
from app.services import contact_service from app.services import contact_service
result = await contact_service.add_contact_person(db_session, uuid.uuid4(), {"name": "Test"})
result = await contact_service.add_contact_person(
db_session, uuid.uuid4(), {"name": "Test"}
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_service_remove_contact_person_not_found(self, db_session): async def test_service_remove_contact_person_not_found(self, db_session):
"""Test remove_contact_person returns False for nonexistent person.""" """Test remove_contact_person returns False for nonexistent person."""
from app.services import contact_service from app.services import contact_service
result = await contact_service.remove_contact_person(db_session, uuid.uuid4(), uuid.uuid4())
result = await contact_service.remove_contact_person(
db_session, uuid.uuid4(), uuid.uuid4()
)
assert result is False assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_service_update_contact_success(self, db_session): async def test_service_update_contact_success(self, db_session):
"""Test update_contact successfully updates fields.""" """Test update_contact successfully updates fields."""
from app.services import contact_service from app.services import contact_service
contact = Contact( contact = Contact(
company_name="Original GmbH", company_name="Original GmbH",
address_country="DE", address_country="DE",
@@ -739,7 +837,11 @@ class TestContactServiceDirect:
await db_session.commit() await db_session.commit()
await db_session.refresh(contact) await db_session.refresh(contact)
updated = await contact_service.update_contact(db_session, contact.id, {"company_name": "Updated GmbH", "phone": "+49 30 999"}) 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.company_name == "Updated GmbH"
assert updated.phone == "+49 30 999" assert updated.phone == "+49 30 999"
@@ -747,6 +849,7 @@ class TestContactServiceDirect:
async def test_service_soft_delete_contact_success(self, db_session): async def test_service_soft_delete_contact_success(self, db_session):
"""Test soft_delete_contact sets deleted_at.""" """Test soft_delete_contact sets deleted_at."""
from app.services import contact_service from app.services import contact_service
contact = Contact( contact = Contact(
company_name="To Delete GmbH", company_name="To Delete GmbH",
address_country="DE", address_country="DE",
@@ -763,6 +866,7 @@ class TestContactServiceDirect:
async def test_service_add_and_remove_contact_person(self, db_session): async def test_service_add_and_remove_contact_person(self, db_session):
"""Test add_contact_person and remove_contact_person.""" """Test add_contact_person and remove_contact_person."""
from app.services import contact_service from app.services import contact_service
contact = Contact( contact = Contact(
company_name="Person Test GmbH", company_name="Person Test GmbH",
address_country="DE", address_country="DE",
@@ -772,17 +876,22 @@ class TestContactServiceDirect:
await db_session.commit() await db_session.commit()
await db_session.refresh(contact) await db_session.refresh(contact)
person = await contact_service.add_contact_person(db_session, contact.id, {"name": "Test Person", "function": "Manager"}) person = await contact_service.add_contact_person(
db_session, contact.id, {"name": "Test Person", "function": "Manager"}
)
assert person is not None assert person is not None
assert person.name == "Test Person" assert person.name == "Test Person"
removed = await contact_service.remove_contact_person(db_session, contact.id, person.id) removed = await contact_service.remove_contact_person(
db_session, contact.id, person.id
)
assert removed is True assert removed is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ust_validation_or_raise_valid(self): async def test_ust_validation_or_raise_valid(self):
"""Test validate_vat_id_or_raise with valid VAT ID.""" """Test validate_vat_id_or_raise with valid VAT ID."""
from app.utils.ust_validation import validate_vat_id_or_raise from app.utils.ust_validation import validate_vat_id_or_raise
result = validate_vat_id_or_raise("DE123456789") result = validate_vat_id_or_raise("DE123456789")
assert result == "DE123456789" assert result == "DE123456789"
@@ -790,6 +899,7 @@ class TestContactServiceDirect:
async def test_ust_validation_or_raise_none(self): async def test_ust_validation_or_raise_none(self):
"""Test validate_vat_id_or_raise with None.""" """Test validate_vat_id_or_raise with None."""
from app.utils.ust_validation import validate_vat_id_or_raise from app.utils.ust_validation import validate_vat_id_or_raise
result = validate_vat_id_or_raise(None) result = validate_vat_id_or_raise(None)
assert result is None assert result is None
@@ -797,6 +907,7 @@ class TestContactServiceDirect:
async def test_ust_validation_or_raise_empty(self): async def test_ust_validation_or_raise_empty(self):
"""Test validate_vat_id_or_raise with empty string.""" """Test validate_vat_id_or_raise with empty string."""
from app.utils.ust_validation import validate_vat_id_or_raise from app.utils.ust_validation import validate_vat_id_or_raise
result = validate_vat_id_or_raise("") result = validate_vat_id_or_raise("")
assert result is None assert result is None
@@ -804,6 +915,7 @@ class TestContactServiceDirect:
async def test_ust_validation_or_raise_invalid(self): async def test_ust_validation_or_raise_invalid(self):
"""Test validate_vat_id_or_raise raises ValueError for invalid format.""" """Test validate_vat_id_or_raise raises ValueError for invalid format."""
from app.utils.ust_validation import validate_vat_id_or_raise from app.utils.ust_validation import validate_vat_id_or_raise
with pytest.raises(ValueError, match="Invalid VAT ID format"): with pytest.raises(ValueError, match="Invalid VAT ID format"):
validate_vat_id_or_raise("DE123") validate_vat_id_or_raise("DE123")
@@ -811,6 +923,7 @@ class TestContactServiceDirect:
async def test_ust_validation_get_country_code(self): async def test_ust_validation_get_country_code(self):
"""Test get_country_code_from_vat_id.""" """Test get_country_code_from_vat_id."""
from app.utils.ust_validation import 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("DE123456789") == "DE"
assert get_country_code_from_vat_id("at123") == "AT" 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("") is None
@@ -821,6 +934,7 @@ class TestContactServiceDirect:
async def test_ust_validation_eu_fallback(self): async def test_ust_validation_eu_fallback(self):
"""Test EU fallback pattern for countries without specific regex.""" """Test EU fallback pattern for countries without specific regex."""
from app.utils.ust_validation import validate_vat_id from app.utils.ust_validation import validate_vat_id
# Ireland (IE) is in EU set but has no specific pattern # Ireland (IE) is in EU set but has no specific pattern
assert validate_vat_id("IE1234567AB") is True assert validate_vat_id("IE1234567AB") is True
# Bulgaria (BG) is in EU set but has no specific pattern # Bulgaria (BG) is in EU set but has no specific pattern
+34 -20
View File
@@ -3,17 +3,12 @@
import base64 import base64
import json import json
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.database import Base, get_db from app.models.copilot import CopilotChat
from app.main import app
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
from app.models.user import User, UserRole
from app.services.auth_service import hash_password
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -37,10 +32,12 @@ async def sample_vehicle_data():
def _mock_chat_json(response_text: str, actions: list | None = None) -> str: def _mock_chat_json(response_text: str, actions: list | None = None) -> str:
"""Build a JSON response string as the AI would return.""" """Build a JSON response string as the AI would return."""
return json.dumps({ return json.dumps(
{
"response": response_text, "response": response_text,
"actions": actions or [], "actions": actions or [],
}) }
)
def _patch_openrouter_chat(content: str): def _patch_openrouter_chat(content: str):
@@ -188,7 +185,9 @@ class TestCopilotAction:
"""POST /api/v1/copilot/action tests.""" """POST /api/v1/copilot/action tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_action_search_vehicles_returns_200(self, admin_client, sample_vehicle_data): async def test_action_search_vehicles_returns_200(
self, admin_client, sample_vehicle_data
):
"""POST /copilot/action with search_vehicles returns 200 + results.""" """POST /copilot/action with search_vehicles returns 200 + results."""
# First create a vehicle # First create a vehicle
await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
@@ -311,7 +310,7 @@ class TestCopilotHistory:
"/api/v1/copilot/chat", "/api/v1/copilot/chat",
json={"message": "Session 1 message"}, json={"message": "Session 1 message"},
) )
resp2 = await admin_client.post( await admin_client.post(
"/api/v1/copilot/chat", "/api/v1/copilot/chat",
json={"message": "Session 2 message"}, json={"message": "Session 2 message"},
) )
@@ -332,16 +331,21 @@ class TestCopilotVoice:
"""POST /api/v1/copilot/voice tests.""" """POST /api/v1/copilot/voice tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_voice_returns_200_with_transcription_and_response(self, admin_client): async def test_voice_returns_200_with_transcription_and_response(
self, admin_client
):
"""POST /copilot/voice returns 200 with transcription + chat response.""" """POST /copilot/voice returns 200 with transcription + chat response."""
mock_content = _mock_chat_json("Ich helfe dir bei der Suche.", []) mock_content = _mock_chat_json("Ich helfe dir bei der Suche.", [])
audio_b64 = base64.b64encode(b"fake-audio-data").decode("utf-8") audio_b64 = base64.b64encode(b"fake-audio-data").decode("utf-8")
with patch( with (
patch(
"app.services.copilot_service.transcribe_audio", "app.services.copilot_service.transcribe_audio",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="Zeige alle LKWs", return_value="Zeige alle LKWs",
), _patch_openrouter_chat(mock_content): ),
_patch_openrouter_chat(mock_content),
):
response = await admin_client.post( response = await admin_client.post(
"/api/v1/copilot/voice", "/api/v1/copilot/voice",
json={"audio": audio_b64, "mime_type": "audio/webm"}, json={"audio": audio_b64, "mime_type": "audio/webm"},
@@ -385,10 +389,12 @@ class TestCopilotServiceUnit:
"""_parse_ai_response correctly parses valid JSON.""" """_parse_ai_response correctly parses valid JSON."""
from app.services.copilot_service import _parse_ai_response from app.services.copilot_service import _parse_ai_response
raw = json.dumps({ raw = json.dumps(
{
"response": "Ich suche LKWs.", "response": "Ich suche LKWs.",
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}], "actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
}) }
)
result = _parse_ai_response(raw) result = _parse_ai_response(raw)
assert result["response"] == "Ich suche LKWs." assert result["response"] == "Ich suche LKWs."
assert len(result["actions"]) == 1 assert len(result["actions"]) == 1
@@ -398,10 +404,16 @@ class TestCopilotServiceUnit:
"""_parse_ai_response handles markdown code fences.""" """_parse_ai_response handles markdown code fences."""
from app.services.copilot_service import _parse_ai_response from app.services.copilot_service import _parse_ai_response
raw = "```json\n" + json.dumps({ raw = (
"```json\n"
+ json.dumps(
{
"response": "Test", "response": "Test",
"actions": [], "actions": [],
}) + "\n```" }
)
+ "\n```"
)
result = _parse_ai_response(raw) result = _parse_ai_response(raw)
assert result["response"] == "Test" assert result["response"] == "Test"
assert result["actions"] == [] assert result["actions"] == []
@@ -419,14 +431,16 @@ class TestCopilotServiceUnit:
"""_parse_ai_response filters out invalid action structures.""" """_parse_ai_response filters out invalid action structures."""
from app.services.copilot_service import _parse_ai_response from app.services.copilot_service import _parse_ai_response
raw = json.dumps({ raw = json.dumps(
{
"response": "Test", "response": "Test",
"actions": [ "actions": [
{"type": "search_vehicles", "params": {}}, {"type": "search_vehicles", "params": {}},
{"invalid": "no type"}, {"invalid": "no type"},
"not a dict", "not a dict",
], ],
}) }
)
result = _parse_ai_response(raw) result = _parse_ai_response(raw)
assert len(result["actions"]) == 1 assert len(result["actions"]) == 1
assert result["actions"][0]["type"] == "search_vehicles" assert result["actions"][0]["type"] == "search_vehicles"
+54 -26
View File
@@ -1,12 +1,9 @@
"""Additional tests to improve coverage for copilot_service functions.""" """Additional tests to improve coverage for copilot_service functions."""
import json import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio
from sqlalchemy import select
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
from app.services import copilot_service from app.services import copilot_service
@@ -18,18 +15,18 @@ class TestCopilotServiceCoverage:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_calls_openrouter_and_persists(self, db_session, admin_user): async def test_chat_calls_openrouter_and_persists(self, db_session, admin_user):
"""chat() calls _call_openrouter_chat and persists both messages.""" """chat() calls _call_openrouter_chat and persists both messages."""
mock_content = json.dumps({ mock_content = json.dumps(
{
"response": "Ich suche LKWs.", "response": "Ich suche LKWs.",
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}], "actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
}) }
)
with patch( with patch(
"app.services.copilot_service._call_openrouter_chat", "app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_content, return_value=mock_content,
) as mock_chat: ) as mock_chat:
result = await copilot_service.chat( result = await copilot_service.chat(db_session, admin_user.id, "Zeige LKWs")
db_session, admin_user.id, "Zeige LKWs"
)
assert mock_chat.call_count == 1 assert mock_chat.call_count == 1
assert result["response"] == "Ich suche LKWs." assert result["response"] == "Ich suche LKWs."
@@ -57,7 +54,9 @@ class TestCopilotServiceCoverage:
assert result["session_id"] == str(session.id) assert result["session_id"] == str(session.id)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_with_invalid_session_id_creates_new(self, db_session, admin_user): async def test_chat_with_invalid_session_id_creates_new(
self, db_session, admin_user
):
"""chat() with invalid session_id creates a new session.""" """chat() with invalid session_id creates a new session."""
mock_content = json.dumps({"response": "OK", "actions": []}) mock_content = json.dumps({"response": "OK", "actions": []})
with patch( with patch(
@@ -79,9 +78,7 @@ class TestCopilotServiceCoverage:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="Das ist kein JSON.", return_value="Das ist kein JSON.",
): ):
result = await copilot_service.chat( result = await copilot_service.chat(db_session, admin_user.id, "Hallo")
db_session, admin_user.id, "Hallo"
)
assert result["response"] == "Das ist kein JSON." assert result["response"] == "Das ist kein JSON."
assert result["actions"] == [] assert result["actions"] == []
@@ -89,10 +86,18 @@ class TestCopilotServiceCoverage:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_with_code_fence_response(self, db_session, admin_user): async def test_chat_with_code_fence_response(self, db_session, admin_user):
"""chat() handles markdown code-fenced JSON response.""" """chat() handles markdown code-fenced JSON response."""
content = "```json\n" + json.dumps({ content = (
"```json\n"
+ json.dumps(
{
"response": "Test", "response": "Test",
"actions": [{"type": "search_contacts", "params": {"search": "Mueller"}}], "actions": [
}) + "\n```" {"type": "search_contacts", "params": {"search": "Mueller"}}
],
}
)
+ "\n```"
)
with patch( with patch(
"app.services.copilot_service._call_openrouter_chat", "app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock, new_callable=AsyncMock,
@@ -157,7 +162,9 @@ class TestCopilotServiceCoverage:
assert msg.session_id == session1.id assert msg.session_id == session1.id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_history_with_invalid_session_id_ignores_filter(self, db_session, admin_user): async def test_get_history_with_invalid_session_id_ignores_filter(
self, db_session, admin_user
):
"""get_history() ignores invalid session_id and returns all.""" """get_history() ignores invalid session_id and returns all."""
session = CopilotSession(user_id=admin_user.id, title="Test") session = CopilotSession(user_id=admin_user.id, title="Test")
db_session.add(session) db_session.add(session)
@@ -268,7 +275,9 @@ class TestCopilotServiceCoverage:
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_confirmed_action_create_vehicle_missing_fields(self, db_session): async def test_execute_confirmed_action_create_vehicle_missing_fields(
self, db_session
):
"""execute_confirmed_action raises ValueError for missing required fields.""" """execute_confirmed_action raises ValueError for missing required fields."""
with pytest.raises(ValueError, match="Missing required fields"): with pytest.raises(ValueError, match="Missing required fields"):
await copilot_service.execute_confirmed_action( await copilot_service.execute_confirmed_action(
@@ -287,8 +296,14 @@ class TestCopilotServiceCoverage:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcribe_audio_with_api_key_calls_openrouter(self): async def test_transcribe_audio_with_api_key_calls_openrouter(self):
"""transcribe_audio calls OpenRouter when API key is set.""" """transcribe_audio calls OpenRouter when API key is set."""
with patch("app.services.copilot_service.settings") as mock_settings, \ with (
patch("app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=" Transkribierter Text ") as mock_chat: patch("app.services.copilot_service.settings") as mock_settings,
patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=" Transkribierter Text ",
) as mock_chat,
):
mock_settings.OPENROUTER_API_KEY = "test-key" mock_settings.OPENROUTER_API_KEY = "test-key"
result = await copilot_service.transcribe_audio(b"fake-audio", "audio/webm") result = await copilot_service.transcribe_audio(b"fake-audio", "audio/webm")
@@ -298,8 +313,14 @@ class TestCopilotServiceCoverage:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcribe_audio_api_error_returns_error_message(self): async def test_transcribe_audio_api_error_returns_error_message(self):
"""transcribe_audio returns error message on API failure.""" """transcribe_audio returns error message on API failure."""
with patch("app.services.copilot_service.settings") as mock_settings, \ with (
patch("app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, side_effect=Exception("API error")): patch("app.services.copilot_service.settings") as mock_settings,
patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
side_effect=Exception("API error"),
),
):
mock_settings.OPENROUTER_API_KEY = "test-key" mock_settings.OPENROUTER_API_KEY = "test-key"
result = await copilot_service.transcribe_audio(b"fake-audio") result = await copilot_service.transcribe_audio(b"fake-audio")
@@ -313,14 +334,17 @@ class TestCopilotServiceCoverage:
audio_b64 = base64.b64encode(b"fake-audio").decode("utf-8") audio_b64 = base64.b64encode(b"fake-audio").decode("utf-8")
mock_content = json.dumps({"response": "Antwort", "actions": []}) mock_content = json.dumps({"response": "Antwort", "actions": []})
with patch( with (
patch(
"app.services.copilot_service.transcribe_audio", "app.services.copilot_service.transcribe_audio",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="Transkription", return_value="Transkription",
), patch( ),
patch(
"app.services.copilot_service._call_openrouter_chat", "app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_content, return_value=mock_content,
),
): ):
result = await copilot_service.voice_chat( result = await copilot_service.voice_chat(
db_session, admin_user.id, audio_b64 db_session, admin_user.id, audio_b64
@@ -337,7 +361,9 @@ class TestCopilotServiceCoverage:
"""_call_openrouter_chat raises ValueError when no API key.""" """_call_openrouter_chat raises ValueError when no API key."""
with patch("app.services.copilot_service.settings") as mock_settings: with patch("app.services.copilot_service.settings") as mock_settings:
mock_settings.OPENROUTER_API_KEY = "" mock_settings.OPENROUTER_API_KEY = ""
with pytest.raises(ValueError, match="OPENROUTER_API_KEY is not configured"): with pytest.raises(
ValueError, match="OPENROUTER_API_KEY is not configured"
):
await copilot_service._call_openrouter_chat([]) await copilot_service._call_openrouter_chat([])
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -349,8 +375,10 @@ class TestCopilotServiceCoverage:
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
with patch("app.services.copilot_service.settings") as mock_settings, \ with (
patch("app.services.copilot_service.httpx.AsyncClient") as mock_client_cls: patch("app.services.copilot_service.settings") as mock_settings,
patch("app.services.copilot_service.httpx.AsyncClient") as mock_client_cls,
):
mock_settings.OPENROUTER_API_KEY = "test-key" mock_settings.OPENROUTER_API_KEY = "test-key"
mock_settings.OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" mock_settings.OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
+45 -16
View File
@@ -5,9 +5,7 @@ import io
import uuid import uuid
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch
import pytest
import pytest_asyncio import pytest_asyncio
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -56,10 +54,14 @@ async def test_buyer_for_datev(db_session: AsyncSession) -> Contact:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def test_completed_sales(db_session: AsyncSession, test_vehicle_for_datev, test_buyer_for_datev) -> list[Sale]: async def test_completed_sales(
db_session: AsyncSession, test_vehicle_for_datev, test_buyer_for_datev
) -> list[Sale]:
"""Create test completed sales within a date range.""" """Create test completed sales within a date range."""
sales = [] sales = []
for i, (day, price) in enumerate([(5, Decimal("30000.00")), (10, Decimal("25000.00")), (15, Decimal("40000.00"))]): for i, (day, price) in enumerate(
[(5, Decimal("30000.00")), (10, Decimal("25000.00")), (15, Decimal("40000.00"))]
):
sale = Sale( sale = Sale(
vehicle_id=test_vehicle_for_datev.id, vehicle_id=test_vehicle_for_datev.id,
buyer_contact_id=test_buyer_for_datev.id, buyer_contact_id=test_buyer_for_datev.id,
@@ -81,10 +83,13 @@ class TestDATEVExportAPI:
async def test_create_export(self, admin_client: AsyncClient, test_completed_sales): async def test_create_export(self, admin_client: AsyncClient, test_completed_sales):
"""POST /datev/export with valid date range returns 201.""" """POST /datev/export with valid date range returns 201."""
response = await admin_client.post("/api/v1/datev/export", json={ response = await admin_client.post(
"/api/v1/datev/export",
json={
"start_date": "2025-01-01", "start_date": "2025-01-01",
"end_date": "2025-01-31", "end_date": "2025-01-31",
}) },
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["start_date"] == "2025-01-01" assert data["start_date"] == "2025-01-01"
@@ -94,19 +99,25 @@ class TestDATEVExportAPI:
async def test_create_export_invalid_date_range(self, admin_client: AsyncClient): async def test_create_export_invalid_date_range(self, admin_client: AsyncClient):
"""POST /datev/export with start > end returns 422.""" """POST /datev/export with start > end returns 422."""
response = await admin_client.post("/api/v1/datev/export", json={ response = await admin_client.post(
"/api/v1/datev/export",
json={
"start_date": "2025-12-31", "start_date": "2025-12-31",
"end_date": "2025-01-01", "end_date": "2025-01-01",
}) },
)
assert response.status_code == 422 assert response.status_code == 422
async def test_list_exports(self, admin_client: AsyncClient, test_completed_sales): async def test_list_exports(self, admin_client: AsyncClient, test_completed_sales):
"""GET /datev/exports returns list of exports.""" """GET /datev/exports returns list of exports."""
# First create an export # First create an export
await admin_client.post("/api/v1/datev/export", json={ await admin_client.post(
"/api/v1/datev/export",
json={
"start_date": "2025-01-01", "start_date": "2025-01-01",
"end_date": "2025-01-31", "end_date": "2025-01-31",
}) },
)
response = await admin_client.get("/api/v1/datev/exports") response = await admin_client.get("/api/v1/datev/exports")
assert response.status_code == 200 assert response.status_code == 200
@@ -116,13 +127,18 @@ class TestDATEVExportAPI:
assert "start_date" in data["items"][0] assert "start_date" in data["items"][0]
assert "end_date" in data["items"][0] assert "end_date" in data["items"][0]
async def test_download_export_csv(self, admin_client: AsyncClient, test_completed_sales): async def test_download_export_csv(
self, admin_client: AsyncClient, test_completed_sales
):
"""GET /datev/exports/:id/download returns CSV content.""" """GET /datev/exports/:id/download returns CSV content."""
# Create export # Create export
create_response = await admin_client.post("/api/v1/datev/export", json={ create_response = await admin_client.post(
"/api/v1/datev/export",
json={
"start_date": "2025-01-01", "start_date": "2025-01-01",
"end_date": "2025-01-31", "end_date": "2025-01-31",
}) },
)
assert create_response.status_code == 201 assert create_response.status_code == 201
export_id = create_response.json()["id"] export_id = create_response.json()["id"]
@@ -145,7 +161,9 @@ class TestDATEVExportAPI:
async def test_download_export_not_found(self, admin_client: AsyncClient): async def test_download_export_not_found(self, admin_client: AsyncClient):
"""GET /datev/exports/:nonexistent/download returns 404.""" """GET /datev/exports/:nonexistent/download returns 404."""
response = await admin_client.get(f"/api/v1/datev/exports/{uuid.uuid4()}/download") response = await admin_client.get(
f"/api/v1/datev/exports/{uuid.uuid4()}/download"
)
assert response.status_code == 404 assert response.status_code == 404
@@ -154,7 +172,14 @@ class TestDATEVCSVFormat:
def test_datev_csv_headers(self): def test_datev_csv_headers(self):
"""DATEV CSV has correct headers.""" """DATEV CSV has correct headers."""
assert DATEV_HEADERS == ["Datum", "Konto", "Gegenkonto", "Betrag", "Belegfeld", "Buchungstext"] assert DATEV_HEADERS == [
"Datum",
"Konto",
"Gegenkonto",
"Betrag",
"Belegfeld",
"Buchungstext",
]
def test_generate_datev_csv_empty(self): def test_generate_datev_csv_empty(self):
"""Generate DATEV CSV with no sales returns only headers.""" """Generate DATEV CSV with no sales returns only headers."""
@@ -170,6 +195,7 @@ class TestDATEVCSVFormat:
def test_generate_datev_csv_with_sales(self, test_completed_sales): def test_generate_datev_csv_with_sales(self, test_completed_sales):
"""Generate DATEV CSV with sales produces correct rows.""" """Generate DATEV CSV with sales produces correct rows."""
# test_completed_sales is a fixture but we need to call it differently for sync test # test_completed_sales is a fixture but we need to call it differently for sync test
# Instead, create mock objects # Instead, create mock objects
class MockVehicle: class MockVehicle:
@@ -225,6 +251,7 @@ class TestDATEVCSVFormat:
def test_datev_csv_amount_format(self): def test_datev_csv_amount_format(self):
"""DATEV CSV amount uses comma as decimal separator.""" """DATEV CSV amount uses comma as decimal separator."""
class MockVehicle: class MockVehicle:
make = "VW" make = "VW"
model = "Crafter" model = "Crafter"
@@ -271,6 +298,8 @@ class TestDATEVExportService:
end_date=date(2025, 1, 31), end_date=date(2025, 1, 31),
) )
exports, total = await datev_service.list_exports(db_session, page=1, page_size=2) exports, total = await datev_service.list_exports(
db_session, page=1, page_size=2
)
assert total >= 3 assert total >= 3
assert len(exports) <= 2 assert len(exports) <= 2
+62 -26
View File
@@ -1,7 +1,6 @@
"""Tests for file upload, list, download, delete, MIME validation, size limit, and thumbnail generation.""" """Tests for file upload, list, download, delete, MIME validation, size limit, and thumbnail generation."""
import io import io
import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -12,16 +11,14 @@ from httpx import ASGITransport, AsyncClient
from PIL import Image from PIL import Image
from app.config import settings from app.config import settings
from app.database import Base, get_db from app.database import get_db
from app.main import app from app.main import app
from app.models.file import File
from app.models.user import User, UserRole
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.auth_service import hash_password
# ---- Test fixtures ---- # ---- Test fixtures ----
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def sample_vehicle_data(): async def sample_vehicle_data():
"""Valid vehicle data for creation.""" """Valid vehicle data for creation."""
@@ -104,6 +101,7 @@ async def _create_test_vehicle(db_session) -> uuid.UUID:
# ---- Tests: File Upload ---- # ---- Tests: File Upload ----
class TestFileUpload: class TestFileUpload:
"""POST /api/v1/vehicles/:id/files tests.""" """POST /api/v1/vehicles/:id/files tests."""
@@ -172,7 +170,9 @@ class TestFileUpload:
assert data["thumbnail_path"] is None assert data["thumbnail_path"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_invalid_mime_type_returns_422(self, admin_client, created_vehicle): async def test_upload_invalid_mime_type_returns_422(
self, admin_client, created_vehicle
):
"""Upload a file with an unsupported MIME type and verify 422.""" """Upload a file with an unsupported MIME type and verify 422."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
files = {"file": ("malware.exe", b"MZ\x90\x00", "application/x-msdownload")} files = {"file": ("malware.exe", b"MZ\x90\x00", "application/x-msdownload")}
@@ -196,7 +196,9 @@ class TestFileUpload:
assert response.status_code == 422, response.text assert response.status_code == 422, response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_oversized_file_returns_413(self, admin_client, created_vehicle): async def test_upload_oversized_file_returns_413(
self, admin_client, created_vehicle
):
"""Upload a file larger than 20MB and verify 413.""" """Upload a file larger than 20MB and verify 413."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
# Create a 21MB file (21 * 1024 * 1024 bytes) # Create a 21MB file (21 * 1024 * 1024 bytes)
@@ -223,7 +225,9 @@ class TestFileUpload:
assert response.status_code == 404, response.text assert response.status_code == 404, response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_without_auth_returns_401(self, test_session_factory, created_vehicle): async def test_upload_without_auth_returns_401(
self, test_session_factory, created_vehicle
):
"""Upload without authentication and verify 401.""" """Upload without authentication and verify 401."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
image_bytes = _make_image_bytes() image_bytes = _make_image_bytes()
@@ -243,7 +247,9 @@ class TestFileUpload:
app.dependency_overrides[get_db] = _override_get_db app.dependency_overrides[get_db] = _override_get_db
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as unauth_client: async with AsyncClient(
transport=transport, base_url="http://test"
) as unauth_client:
response = await unauth_client.post( response = await unauth_client.post(
f"/api/v1/vehicles/{vehicle_id}/files", f"/api/v1/vehicles/{vehicle_id}/files",
files=files, files=files,
@@ -254,11 +260,14 @@ class TestFileUpload:
# ---- Tests: File List ---- # ---- Tests: File List ----
class TestFileList: class TestFileList:
"""GET /api/v1/vehicles/:id/files tests.""" """GET /api/v1/vehicles/:id/files tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_files_returns_200_with_pagination(self, admin_client, created_vehicle): async def test_list_files_returns_200_with_pagination(
self, admin_client, created_vehicle
):
"""List files for a vehicle returns 200 with paginated response.""" """List files for a vehicle returns 200 with paginated response."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
# Upload a file first # Upload a file first
@@ -284,9 +293,7 @@ class TestFileList:
async def test_list_files_empty_returns_200(self, admin_client, created_vehicle): async def test_list_files_empty_returns_200(self, admin_client, created_vehicle):
"""List files for a vehicle with no files returns 200 with empty list.""" """List files for a vehicle with no files returns 200 with empty list."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
response = await admin_client.get( response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/files")
f"/api/v1/vehicles/{vehicle_id}/files"
)
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
data = response.json() data = response.json()
assert data["total"] == 0 assert data["total"] == 0
@@ -302,6 +309,7 @@ class TestFileList:
# ---- Tests: File Download ---- # ---- Tests: File Download ----
class TestFileDownload: class TestFileDownload:
"""GET /api/v1/vehicles/:id/files/:fileId tests.""" """GET /api/v1/vehicles/:id/files/:fileId tests."""
@@ -325,7 +333,9 @@ class TestFileDownload:
assert len(response.content) == len(image_bytes) assert len(response.content) == len(image_bytes)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_nonexistent_file_returns_404(self, admin_client, created_vehicle): async def test_download_nonexistent_file_returns_404(
self, admin_client, created_vehicle
):
"""Download a non-existent file returns 404.""" """Download a non-existent file returns 404."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
fake_file_id = str(uuid.uuid4()) fake_file_id = str(uuid.uuid4())
@@ -337,6 +347,7 @@ class TestFileDownload:
# ---- Tests: File Delete ---- # ---- Tests: File Delete ----
class TestFileDelete: class TestFileDelete:
"""DELETE /api/v1/vehicles/:id/files/:fileId tests.""" """DELETE /api/v1/vehicles/:id/files/:fileId tests."""
@@ -361,14 +372,14 @@ class TestFileDelete:
assert data["id"] == file_id assert data["id"] == file_id
# Verify file is gone from list # Verify file is gone from list
list_resp = await admin_client.get( list_resp = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/files")
f"/api/v1/vehicles/{vehicle_id}/files"
)
assert list_resp.status_code == 200 assert list_resp.status_code == 200
assert list_resp.json()["total"] == 0 assert list_resp.json()["total"] == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_nonexistent_file_returns_404(self, admin_client, created_vehicle): async def test_delete_nonexistent_file_returns_404(
self, admin_client, created_vehicle
):
"""Delete a non-existent file returns 404.""" """Delete a non-existent file returns 404."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
fake_file_id = str(uuid.uuid4()) fake_file_id = str(uuid.uuid4())
@@ -380,77 +391,96 @@ class TestFileDelete:
# ---- Tests: MIME Type Validation ---- # ---- Tests: MIME Type Validation ----
class TestMIMEValidation: class TestMIMEValidation:
"""Unit tests for MIME type validation.""" """Unit tests for MIME type validation."""
def test_validate_jpeg_mime_type(self): def test_validate_jpeg_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("image/jpeg", "photo.jpg") is True assert validate_mime_type("image/jpeg", "photo.jpg") is True
assert validate_mime_type("image/jpeg", "photo.jpeg") is True assert validate_mime_type("image/jpeg", "photo.jpeg") is True
def test_validate_png_mime_type(self): def test_validate_png_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("image/png", "photo.png") is True assert validate_mime_type("image/png", "photo.png") is True
def test_validate_webp_mime_type(self): def test_validate_webp_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("image/webp", "photo.webp") is True assert validate_mime_type("image/webp", "photo.webp") is True
def test_validate_pdf_mime_type(self): def test_validate_pdf_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("application/pdf", "doc.pdf") is True assert validate_mime_type("application/pdf", "doc.pdf") is True
def test_validate_doc_mime_type(self): def test_validate_doc_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("application/msword", "doc.doc") is True assert validate_mime_type("application/msword", "doc.doc") is True
def test_validate_docx_mime_type(self): def test_validate_docx_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type(
assert (
validate_mime_type(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc.docx", "doc.docx",
) is True )
is True
)
def test_reject_exe_mime_type(self): def test_reject_exe_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("application/x-msdownload", "malware.exe") is False assert validate_mime_type("application/x-msdownload", "malware.exe") is False
def test_reject_text_mime_type(self): def test_reject_text_mime_type(self):
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("text/plain", "notes.txt") is False assert validate_mime_type("text/plain", "notes.txt") is False
def test_reject_mismatched_extension(self): def test_reject_mismatched_extension(self):
"""MIME type image/jpeg with .png extension should fail.""" """MIME type image/jpeg with .png extension should fail."""
from app.services.file_service import validate_mime_type from app.services.file_service import validate_mime_type
assert validate_mime_type("image/jpeg", "photo.png") is False assert validate_mime_type("image/jpeg", "photo.png") is False
# ---- Tests: File Size Validation ---- # ---- Tests: File Size Validation ----
class TestFileSizeValidation: class TestFileSizeValidation:
"""Unit tests for file size validation.""" """Unit tests for file size validation."""
def test_validate_small_file_size(self): def test_validate_small_file_size(self):
from app.services.file_service import validate_file_size from app.services.file_service import validate_file_size
assert validate_file_size(1024, max_size_mb=20) is True assert validate_file_size(1024, max_size_mb=20) is True
def test_validate_exact_20mb_file_size(self): def test_validate_exact_20mb_file_size(self):
from app.services.file_service import validate_file_size from app.services.file_service import validate_file_size
exact_20mb = 20 * 1024 * 1024 exact_20mb = 20 * 1024 * 1024
assert validate_file_size(exact_20mb, max_size_mb=20) is True assert validate_file_size(exact_20mb, max_size_mb=20) is True
def test_reject_oversized_file(self): def test_reject_oversized_file(self):
from app.services.file_service import validate_file_size from app.services.file_service import validate_file_size
over_20mb = 20 * 1024 * 1024 + 1 over_20mb = 20 * 1024 * 1024 + 1
assert validate_file_size(over_20mb, max_size_mb=20) is False assert validate_file_size(over_20mb, max_size_mb=20) is False
def test_validate_zero_byte_file(self): def test_validate_zero_byte_file(self):
from app.services.file_service import validate_file_size from app.services.file_service import validate_file_size
assert validate_file_size(0, max_size_mb=20) is True assert validate_file_size(0, max_size_mb=20) is True
# ---- Tests: Thumbnail Generation ---- # ---- Tests: Thumbnail Generation ----
class TestThumbnailGeneration: class TestThumbnailGeneration:
"""Tests for thumbnail generation utility.""" """Tests for thumbnail generation utility."""
@@ -534,6 +564,7 @@ class TestThumbnailGeneration:
def test_is_image_mime_type(self): def test_is_image_mime_type(self):
"""Test is_image_mime_type helper.""" """Test is_image_mime_type helper."""
from app.utils.thumbnails import is_image_mime_type from app.utils.thumbnails import is_image_mime_type
assert is_image_mime_type("image/jpeg") is True assert is_image_mime_type("image/jpeg") is True
assert is_image_mime_type("image/png") is True assert is_image_mime_type("image/png") is True
assert is_image_mime_type("image/webp") is True assert is_image_mime_type("image/webp") is True
@@ -543,6 +574,7 @@ class TestThumbnailGeneration:
# ---- Tests: File Service Unit Tests ---- # ---- Tests: File Service Unit Tests ----
class TestFileServiceUnit: class TestFileServiceUnit:
"""Unit tests for file service functions.""" """Unit tests for file service functions."""
@@ -622,7 +654,9 @@ class TestFileServiceUnit:
file_path = Path(file_record.file_path) file_path = Path(file_record.file_path)
assert file_path.exists() assert file_path.exists()
deleted = await file_service.delete_file(db_session, vehicle_id, file_record.id) deleted = await file_service.delete_file(
db_session, vehicle_id, file_record.id
)
assert deleted is not None assert deleted is not None
assert not file_path.exists() assert not file_path.exists()
@@ -631,9 +665,7 @@ class TestFileServiceUnit:
"""Test that delete_file returns None for non-existent file.""" """Test that delete_file returns None for non-existent file."""
from app.services import file_service from app.services import file_service
result = await file_service.delete_file( result = await file_service.delete_file(db_session, uuid.uuid4(), uuid.uuid4())
db_session, uuid.uuid4(), uuid.uuid4()
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -682,7 +714,9 @@ class TestFileServiceUnit:
mime_type="image/jpeg", mime_type="image/jpeg",
) )
retrieved = await file_service.get_file(db_session, vehicle_id, file_record.id) retrieved = await file_service.get_file(
db_session, vehicle_id, file_record.id
)
assert retrieved is not None assert retrieved is not None
assert retrieved.id == file_record.id assert retrieved.id == file_record.id
assert retrieved.original_filename == "test.jpg" assert retrieved.original_filename == "test.jpg"
@@ -704,5 +738,7 @@ class TestFileServiceUnit:
) )
wrong_vehicle_id = uuid.uuid4() wrong_vehicle_id = uuid.uuid4()
retrieved = await file_service.get_file(db_session, wrong_vehicle_id, file_record.id) retrieved = await file_service.get_file(
db_session, wrong_vehicle_id, file_record.id
)
assert retrieved is None assert retrieved is None
+27 -13
View File
@@ -6,8 +6,6 @@ from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.vehicle import MobileDeListing, Vehicle from app.models.vehicle import MobileDeListing, Vehicle
from app.services import mobilede_service from app.services import mobilede_service
@@ -157,7 +155,9 @@ class TestPushListing:
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 201 mock_response.status_code = 201
mock_response.json.return_value = {"id": "ad-123"} mock_response.json.return_value = {"id": "ad-123"}
@@ -184,7 +184,9 @@ class TestPushListing:
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 400 mock_response.status_code = 400
mock_response.text = "Bad Request" mock_response.text = "Bad Request"
@@ -212,9 +214,13 @@ class TestPushListing:
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) mock_client.post = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
@@ -243,7 +249,9 @@ class TestUpdateListing:
db_session.add(listing) db_session.add(listing)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@@ -296,7 +304,9 @@ class TestDeleteListing:
db_session.add(listing) db_session.add(listing)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 204 mock_response.status_code = 204
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@@ -340,8 +350,6 @@ class TestGetListingStatus:
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
from datetime import datetime, timezone, timedelta
listing1 = MobileDeListing( listing1 = MobileDeListing(
vehicle_id=vehicle.id, vehicle_id=vehicle.id,
sync_status="fehler", sync_status="fehler",
@@ -392,7 +400,9 @@ class TestRetryFailedListing:
db_session.add(listing) db_session.add(listing)
await db_session.flush() await db_session.flush()
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 201 mock_response.status_code = 201
mock_response.json.return_value = {"id": "ad-789"} mock_response.json.return_value = {"id": "ad-789"}
@@ -403,7 +413,9 @@ class TestRetryFailedListing:
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle) result = await mobilede_service.retry_failed_listing(
db_session, listing, vehicle
)
assert result.sync_status == "synced" assert result.sync_status == "synced"
assert result.ad_id == "ad-789" assert result.ad_id == "ad-789"
@@ -423,7 +435,9 @@ class TestRetryFailedListing:
db_session.add(listing) db_session.add(listing)
await db_session.flush() await db_session.flush()
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle) result = await mobilede_service.retry_failed_listing(
db_session, listing, vehicle
)
assert result.sync_status == "fehler" assert result.sync_status == "fehler"
assert "Max retries" in result.error_log assert "Max retries" in result.error_log
+28 -14
View File
@@ -2,7 +2,6 @@
import io import io
import uuid import uuid
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -11,15 +10,14 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import Base, get_db from app.database import get_db
from app.main import app from app.main import app
from app.models.ocr_result import OCRResult, OCRStatus from app.models.ocr_result import OCRResult, OCRStatus
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services import ocr_service from app.services import ocr_service
from app.services.ocr_service import CONFIDENCE_THRESHOLD
# Ensure all models are registered with Base.metadata # Ensure all models are registered with Base.metadata
from app.models import user, vehicle, ocr_result # noqa: F401 from app.models import user, vehicle as vehicle_model, ocr_result # noqa: F401
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -227,9 +225,7 @@ class TestOCRService:
assert result.structured_data["vin"] == "WDB9066351L123456" assert result.structured_data["vin"] == "WDB9066351L123456"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_ocr_low_confidence( async def test_process_ocr_low_confidence(self, db_session: AsyncSession, tmp_path):
self, db_session: AsyncSession, tmp_path
):
"""Test OCR processing with low confidence sets status to manual_review.""" """Test OCR processing with low confidence sets status to manual_review."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file( ocr_result = await ocr_service.upload_file(
@@ -359,6 +355,7 @@ class TestOCRRouter:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def ocr_client(self, test_session_factory, admin_token): async def ocr_client(self, test_session_factory, admin_token):
"""HTTP client with DB override and admin auth.""" """HTTP client with DB override and admin auth."""
async def _override_get_db(): async def _override_get_db():
async with test_session_factory() as session: async with test_session_factory() as session:
try: try:
@@ -382,10 +379,12 @@ class TestOCRRouter:
"""POST /api/v1/ocr/upload with valid image returns 202.""" """POST /api/v1/ocr/upload with valid image returns 202."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
# Patch background task to avoid actual processing # Patch background task to avoid actual processing
with patch("app.routers.ocr.run_ocr_processing") as mock_task: with patch("app.routers.ocr.run_ocr_processing"):
response = await ocr_client.post( response = await ocr_client.post(
"/api/v1/ocr/upload", "/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")}, files={
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
},
) )
assert response.status_code == 202 assert response.status_code == 202
data = response.json() data = response.json()
@@ -418,7 +417,9 @@ class TestOCRRouter:
with patch("app.routers.ocr.run_ocr_processing"): with patch("app.routers.ocr.run_ocr_processing"):
upload_resp = await ocr_client.post( upload_resp = await ocr_client.post(
"/api/v1/ocr/upload", "/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")}, files={
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
},
) )
result_id = upload_resp.json()["ocr_result_id"] result_id = upload_resp.json()["ocr_result_id"]
@@ -443,7 +444,13 @@ class TestOCRRouter:
for i in range(3): for i in range(3):
await ocr_client.post( await ocr_client.post(
"/api/v1/ocr/upload", "/api/v1/ocr/upload",
files={"file": (f"scan{i}.png", io.BytesIO(b"fake-image"), "image/png")}, files={
"file": (
f"scan{i}.png",
io.BytesIO(b"fake-image"),
"image/png",
)
},
) )
response = await ocr_client.get("/api/v1/ocr/results") response = await ocr_client.get("/api/v1/ocr/results")
@@ -485,7 +492,9 @@ class TestOCRRouter:
with patch("app.routers.ocr.run_ocr_processing"): with patch("app.routers.ocr.run_ocr_processing"):
upload_resp = await ocr_client.post( upload_resp = await ocr_client.post(
"/api/v1/ocr/upload", "/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")}, files={
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
},
data={"vehicle_id": str(test_vehicle.id)}, data={"vehicle_id": str(test_vehicle.id)},
) )
result_id = upload_resp.json()["ocr_result_id"] result_id = upload_resp.json()["ocr_result_id"]
@@ -493,8 +502,11 @@ class TestOCRRouter:
# Manually set structured data via direct DB session # Manually set structured data via direct DB session
from tests.conftest import TEST_DATABASE_URL from tests.conftest import TEST_DATABASE_URL
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(TEST_DATABASE_URL) engine = create_async_engine(TEST_DATABASE_URL)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with factory() as session: async with factory() as session:
stmt = select(OCRResult).where(OCRResult.id == uuid.UUID(result_id)) stmt = select(OCRResult).where(OCRResult.id == uuid.UUID(result_id))
res = await session.execute(stmt) res = await session.execute(stmt)
@@ -527,7 +539,9 @@ class TestOpenRouterClient:
"""Test parsing a valid JSON response.""" """Test parsing a valid JSON response."""
from app.utils.openrouter import _parse_response from app.utils.openrouter import _parse_response
raw = '{"brand": "BMW", "model": "X5", "vin": "ABC123", "confidence_score": 0.9}' raw = (
'{"brand": "BMW", "model": "X5", "vin": "ABC123", "confidence_score": 0.9}'
)
result = _parse_response(raw) result = _parse_response(raw)
assert result["structured_data"]["brand"] == "BMW" assert result["structured_data"]["brand"] == "BMW"
assert result["confidence_score"] == 0.9 assert result["confidence_score"] == 0.9
+88 -65
View File
@@ -5,12 +5,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import Base, get_db from app.models.retouch import RetouchStatus
from app.main import app
from app.models.retouch import RetouchResult, RetouchStatus
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services import retouch_service, price_compare_service from app.services import retouch_service, price_compare_service
@@ -83,7 +81,9 @@ class TestRetouchService:
assert "red" in prompt assert "red" in prompt
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_retouch_file_success(self, db_session: AsyncSession, tmp_path): async def test_upload_retouch_file_success(
self, db_session: AsyncSession, tmp_path
):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, db=db_session,
@@ -132,12 +132,16 @@ class TestRetouchService:
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path): async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
await retouch_service.upload_retouch_file( await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="img1.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="img1.png",
mime_type="image/png",
) )
await retouch_service.upload_retouch_file( await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="img2.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="img2.png",
mime_type="image/png",
) )
await db_session.commit() await db_session.commit()
items, total = await retouch_service.list_results(db_session) items, total = await retouch_service.list_results(db_session)
@@ -150,13 +154,17 @@ class TestRetouchService:
): ):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
await retouch_service.upload_retouch_file( await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="img1.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="img1.png",
mime_type="image/png",
vehicle_id=test_vehicle.id, vehicle_id=test_vehicle.id,
) )
await retouch_service.upload_retouch_file( await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="img2.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="img2.png",
mime_type="image/png",
) )
await db_session.commit() await db_session.commit()
items, total = await retouch_service.list_results( items, total = await retouch_service.list_results(
@@ -171,20 +179,22 @@ class TestRetouchService:
"""Test process_retouch with mocked Flux.1-Pro call.""" """Test process_retouch with mocked Flux.1-Pro call."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="truck.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
) )
await db_session.commit() await db_session.commit()
# Mock the _call_flux_pro function to return fake retouched bytes # Mock the _call_flux_pro function to return fake retouched bytes
retouched_bytes = b"\x89PNG\r\n\x1a\n" + b"\xFF" * 1016 retouched_bytes = b"\x89PNG\r\n\x1a\n" + b"\xff" * 1016
with patch.object( with patch.object(
retouch_service, "_call_flux_pro", retouch_service,
new_callable=AsyncMock, return_value=retouched_bytes, "_call_flux_pro",
new_callable=AsyncMock,
return_value=retouched_bytes,
): ):
processed = await retouch_service.process_retouch( processed = await retouch_service.process_retouch(db_session, result.id)
db_session, result.id
)
await db_session.commit() await db_session.commit()
assert processed.status == RetouchStatus.completed.value assert processed.status == RetouchStatus.completed.value
@@ -197,19 +207,20 @@ class TestRetouchService:
"""Test process_retouch sets failed status on OpenRouter error.""" """Test process_retouch sets failed status on OpenRouter error."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="truck.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
) )
await db_session.commit() await db_session.commit()
with patch.object( with patch.object(
retouch_service, "_call_flux_pro", retouch_service,
"_call_flux_pro",
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=Exception("OpenRouter unavailable"), side_effect=Exception("OpenRouter unavailable"),
): ):
processed = await retouch_service.process_retouch( processed = await retouch_service.process_retouch(db_session, result.id)
db_session, result.id
)
await db_session.commit() await db_session.commit()
assert processed.status == RetouchStatus.failed.value assert processed.status == RetouchStatus.failed.value
@@ -227,7 +238,9 @@ class TestRetouchService:
async def test_call_flux_pro_no_api_key(self): async def test_call_flux_pro_no_api_key(self):
"""Test _call_flux_pro raises ValueError when no API key configured.""" """Test _call_flux_pro raises ValueError when no API key configured."""
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", ""): with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", ""):
with pytest.raises(ValueError, match="OPENROUTER_API_KEY is not configured"): with pytest.raises(
ValueError, match="OPENROUTER_API_KEY is not configured"
):
await retouch_service._call_flux_pro( await retouch_service._call_flux_pro(
image_bytes=b"fake-image", image_bytes=b"fake-image",
mime_type="image/png", mime_type="image/png",
@@ -235,9 +248,12 @@ class TestRetouchService:
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_flux_pro_success_data_uri(self, db_session: AsyncSession, tmp_path): async def test_call_flux_pro_success_data_uri(
self, db_session: AsyncSession, tmp_path
):
"""Test _call_flux_pro with mocked httpx returning base64 data URI.""" """Test _call_flux_pro with mocked httpx returning base64 data URI."""
import base64 as b64mod import base64 as b64mod
retouched = b"\x89PNG\r\n\x1a\nretouched-data" retouched = b"\x89PNG\r\n\x1a\nretouched-data"
b64_retouched = b64mod.b64encode(retouched).decode("utf-8") b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
@@ -245,11 +261,7 @@ class TestRetouchService:
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
"choices": [ "choices": [
{ {"message": {"content": f"data:image/png;base64,{b64_retouched}"}}
"message": {
"content": f"data:image/png;base64,{b64_retouched}"
}
}
] ]
} }
@@ -259,7 +271,10 @@ class TestRetouchService:
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"): with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client): with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro( result = await retouch_service._call_flux_pro(
image_bytes=b"fake-image", image_bytes=b"fake-image",
mime_type="image/png", mime_type="image/png",
@@ -268,9 +283,12 @@ class TestRetouchService:
assert result == retouched assert result == retouched
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_flux_pro_success_list_content(self, db_session: AsyncSession, tmp_path): async def test_call_flux_pro_success_list_content(
self, db_session: AsyncSession, tmp_path
):
"""Test _call_flux_pro with content as list of parts.""" """Test _call_flux_pro with content as list of parts."""
import base64 as b64mod import base64 as b64mod
retouched = b"\x89PNG\r\n\x1a\nlist-content" retouched = b"\x89PNG\r\n\x1a\nlist-content"
b64_retouched = b64mod.b64encode(retouched).decode("utf-8") b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
@@ -282,7 +300,12 @@ class TestRetouchService:
"message": { "message": {
"content": [ "content": [
{"type": "text", "text": "Here is the image"}, {"type": "text", "text": "Here is the image"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_retouched}"}}, {
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{b64_retouched}"
},
},
] ]
} }
} }
@@ -295,7 +318,10 @@ class TestRetouchService:
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"): with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client): with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro( result = await retouch_service._call_flux_pro(
image_bytes=b"fake-image", image_bytes=b"fake-image",
mime_type="image/png", mime_type="image/png",
@@ -309,13 +335,7 @@ class TestRetouchService:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
"choices": [ "choices": [{"message": {"content": "Sorry, I cannot process this image."}}]
{
"message": {
"content": "Sorry, I cannot process this image."
}
}
]
} }
mock_client = AsyncMock() mock_client = AsyncMock()
@@ -325,7 +345,10 @@ class TestRetouchService:
original = b"original-image-bytes" original = b"original-image-bytes"
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"): with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch("app.services.retouch_service.httpx.AsyncClient", return_value=mock_client): with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro( result = await retouch_service._call_flux_pro(
image_bytes=original, image_bytes=original,
mime_type="image/png", mime_type="image/png",
@@ -341,9 +364,7 @@ class TestPriceCompareService:
async def test_compare_prices_success( async def test_compare_prices_success(
self, db_session: AsyncSession, test_vehicle: Vehicle self, db_session: AsyncSession, test_vehicle: Vehicle
): ):
result = await price_compare_service.compare_prices( result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
db_session, test_vehicle.id
)
assert result.vehicle_id == test_vehicle.id assert result.vehicle_id == test_vehicle.id
assert len(result.comparable_listings) > 0 assert len(result.comparable_listings) > 0
assert result.average_price is not None assert result.average_price is not None
@@ -355,17 +376,13 @@ class TestPriceCompareService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_compare_prices_vehicle_not_found(self, db_session: AsyncSession): async def test_compare_prices_vehicle_not_found(self, db_session: AsyncSession):
with pytest.raises(ValueError, match="not found"): with pytest.raises(ValueError, match="not found"):
await price_compare_service.compare_prices( await price_compare_service.compare_prices(db_session, uuid.uuid4())
db_session, uuid.uuid4()
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_compare_prices_listings_have_correct_make_model( async def test_compare_prices_listings_have_correct_make_model(
self, db_session: AsyncSession, test_vehicle: Vehicle self, db_session: AsyncSession, test_vehicle: Vehicle
): ):
result = await price_compare_service.compare_prices( result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
db_session, test_vehicle.id
)
for listing in result.comparable_listings: for listing in result.comparable_listings:
assert listing.make == test_vehicle.make assert listing.make == test_vehicle.make
assert listing.model == test_vehicle.model assert listing.model == test_vehicle.model
@@ -375,10 +392,10 @@ class TestPriceCompareService:
async def test_compare_prices_average_calculation( async def test_compare_prices_average_calculation(
self, db_session: AsyncSession, test_vehicle: Vehicle self, db_session: AsyncSession, test_vehicle: Vehicle
): ):
result = await price_compare_service.compare_prices( result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
db_session, test_vehicle.id expected_avg = sum(
) listing.price for listing in result.comparable_listings
expected_avg = sum(l.price for l in result.comparable_listings) / len(result.comparable_listings) ) / len(result.comparable_listings)
assert abs(result.average_price - round(expected_avg, 2)) < 0.01 assert abs(result.average_price - round(expected_avg, 2)) < 0.01
@@ -447,8 +464,10 @@ class TestRetouchRouter:
"""GET /retouch/results/:id returns 200 with completed status.""" """GET /retouch/results/:id returns 200 with completed status."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="truck.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
) )
result.status = RetouchStatus.completed.value result.status = RetouchStatus.completed.value
result.retouched_file_path = str(tmp_path / "retouched.png") result.retouched_file_path = str(tmp_path / "retouched.png")
@@ -468,8 +487,10 @@ class TestRetouchRouter:
"""GET /retouch/results/:id before completion returns 200 with processing status.""" """GET /retouch/results/:id before completion returns 200 with processing status."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="truck.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
) )
result.status = RetouchStatus.processing.value result.status = RetouchStatus.processing.value
await db_session.commit() await db_session.commit()
@@ -487,8 +508,10 @@ class TestRetouchRouter:
"""GET /retouch/results/:id with failed status returns 200 + error.""" """GET /retouch/results/:id with failed status returns 200 + error."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)): with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file( result = await retouch_service.upload_retouch_file(
db=db_session, file_bytes=_fake_image(), db=db_session,
file_name="truck.png", mime_type="image/png", file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
) )
result.status = RetouchStatus.failed.value result.status = RetouchStatus.failed.value
result.error_message = "OpenRouter unavailable" result.error_message = "OpenRouter unavailable"
+72 -28
View File
@@ -3,9 +3,8 @@
import uuid import uuid
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from unittest.mock import patch, MagicMock from unittest.mock import patch
import pytest
import pytest_asyncio import pytest_asyncio
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,7 +13,6 @@ from app.models.sale import Sale
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.models.contact import Contact from app.models.contact import Contact
from app.utils.contract_pdf import build_contract_html from app.utils.contract_pdf import build_contract_html
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -75,7 +73,9 @@ async def test_seller(db_session: AsyncSession) -> Contact:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def test_sale(db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact) -> Sale: async def test_sale(
db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact
) -> Sale:
"""Create a test sale.""" """Create a test sale."""
sale = Sale( sale = Sale(
vehicle_id=test_vehicle.id, vehicle_id=test_vehicle.id,
@@ -109,16 +109,21 @@ class TestSaleCRUD:
assert data["page"] == 1 assert data["page"] == 1
assert data["page_size"] == 20 assert data["page_size"] == 20
async def test_create_sale(self, admin_client: AsyncClient, test_vehicle, test_buyer): async def test_create_sale(
self, admin_client: AsyncClient, test_vehicle, test_buyer
):
"""POST /sales with valid data returns 201.""" """POST /sales with valid data returns 201."""
response = await admin_client.post("/api/v1/sales/", json={ response = await admin_client.post(
"/api/v1/sales/",
json={
"vehicle_id": str(test_vehicle.id), "vehicle_id": str(test_vehicle.id),
"buyer_contact_id": str(test_buyer.id), "buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00", "sale_price": "45000.00",
"sale_date": "2025-01-15", "sale_date": "2025-01-15",
"status": "draft", "status": "draft",
"is_gwg": False, "is_gwg": False,
}) },
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["vehicle_id"] == str(test_vehicle.id) assert data["vehicle_id"] == str(test_vehicle.id)
@@ -127,20 +132,30 @@ class TestSaleCRUD:
assert data["status"] == "draft" assert data["status"] == "draft"
assert data["is_gwg"] is False assert data["is_gwg"] is False
async def test_create_sale_without_vehicle_id(self, admin_client: AsyncClient, test_buyer): async def test_create_sale_without_vehicle_id(
self, admin_client: AsyncClient, test_buyer
):
"""POST /sales without vehicle_id returns 422.""" """POST /sales without vehicle_id returns 422."""
response = await admin_client.post("/api/v1/sales/", json={ response = await admin_client.post(
"/api/v1/sales/",
json={
"buyer_contact_id": str(test_buyer.id), "buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00", "sale_price": "45000.00",
}) },
)
assert response.status_code == 422 assert response.status_code == 422
async def test_create_sale_without_buyer_contact_id(self, admin_client: AsyncClient, test_vehicle): async def test_create_sale_without_buyer_contact_id(
self, admin_client: AsyncClient, test_vehicle
):
"""POST /sales without buyer_contact_id returns 422.""" """POST /sales without buyer_contact_id returns 422."""
response = await admin_client.post("/api/v1/sales/", json={ response = await admin_client.post(
"/api/v1/sales/",
json={
"vehicle_id": str(test_vehicle.id), "vehicle_id": str(test_vehicle.id),
"sale_price": "45000.00", "sale_price": "45000.00",
}) },
)
assert response.status_code == 422 assert response.status_code == 422
async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale): async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale):
@@ -159,16 +174,21 @@ class TestSaleCRUD:
async def test_update_sale(self, admin_client: AsyncClient, test_sale): async def test_update_sale(self, admin_client: AsyncClient, test_sale):
"""PUT /sales/:id updates sale fields.""" """PUT /sales/:id updates sale fields."""
response = await admin_client.put(f"/api/v1/sales/{test_sale.id}", json={ response = await admin_client.put(
f"/api/v1/sales/{test_sale.id}",
json={
"sale_price": "42000.00", "sale_price": "42000.00",
"status": "completed", "status": "completed",
}) },
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["sale_price"] == "42000.00" assert data["sale_price"] == "42000.00"
assert data["status"] == "completed" assert data["status"] == "completed"
async def test_delete_sale_cancels_and_restores_vehicle(self, admin_client: AsyncClient, test_sale, db_session): async def test_delete_sale_cancels_and_restores_vehicle(
self, admin_client: AsyncClient, test_sale, db_session
):
"""DELETE /sales/:id cancels sale and restores vehicle status to 'available'.""" """DELETE /sales/:id cancels sale and restores vehicle status to 'available'."""
# First set vehicle to sold (as create_sale would) # First set vehicle to sold (as create_sale would)
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id) vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
@@ -193,9 +213,13 @@ class TestSaleCRUD:
for item in data["items"]: for item in data["items"]:
assert item["status"] == "completed" assert item["status"] == "completed"
async def test_list_sales_with_date_filter(self, admin_client: AsyncClient, test_sale): async def test_list_sales_with_date_filter(
self, admin_client: AsyncClient, test_sale
):
"""GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range.""" """GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range."""
response = await admin_client.get("/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31") response = await admin_client.get(
"/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["total"] >= 1 assert data["total"] >= 1
@@ -204,22 +228,29 @@ class TestSaleCRUD:
class TestSaleVehicleStatus: class TestSaleVehicleStatus:
"""Test vehicle status changes on sale create/delete.""" """Test vehicle status changes on sale create/delete."""
async def test_create_sale_sets_vehicle_sold(self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session): async def test_create_sale_sets_vehicle_sold(
self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session
):
"""Creating a sale sets vehicle availability to 'sold'.""" """Creating a sale sets vehicle availability to 'sold'."""
response = await admin_client.post("/api/v1/sales/", json={ response = await admin_client.post(
"/api/v1/sales/",
json={
"vehicle_id": str(test_vehicle.id), "vehicle_id": str(test_vehicle.id),
"buyer_contact_id": str(test_buyer.id), "buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00", "sale_price": "45000.00",
"sale_date": "2025-01-15", "sale_date": "2025-01-15",
"status": "draft", "status": "draft",
}) },
)
assert response.status_code == 201 assert response.status_code == 201
# Verify vehicle status # Verify vehicle status
await db_session.refresh(test_vehicle) await db_session.refresh(test_vehicle)
assert test_vehicle.availability == "sold" assert test_vehicle.availability == "sold"
async def test_cancel_sale_restores_vehicle_available(self, admin_client: AsyncClient, test_sale, db_session): async def test_cancel_sale_restores_vehicle_available(
self, admin_client: AsyncClient, test_sale, db_session
):
"""Cancelling a sale restores vehicle availability to 'available'.""" """Cancelling a sale restores vehicle availability to 'available'."""
# Set vehicle to sold first # Set vehicle to sold first
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id) vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
@@ -246,15 +277,20 @@ class TestContractPDF:
assert data["sale_id"] == str(test_sale.id) assert data["sale_id"] == str(test_sale.id)
assert "contract_pdf_path" in data assert "contract_pdf_path" in data
async def test_download_contract_not_found(self, admin_client: AsyncClient, test_sale): async def test_download_contract_not_found(
self, admin_client: AsyncClient, test_sale
):
"""GET /sales/:id/contract returns 404 if no PDF generated.""" """GET /sales/:id/contract returns 404 if no PDF generated."""
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract") response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
assert response.status_code == 404 assert response.status_code == 404
async def test_download_contract_pdf(self, admin_client: AsyncClient, test_sale, db_session): async def test_download_contract_pdf(
self, admin_client: AsyncClient, test_sale, db_session
):
"""GET /sales/:id/contract returns PDF content.""" """GET /sales/:id/contract returns PDF content."""
# Create a fake PDF file # Create a fake PDF file
import os import os
os.makedirs("/tmp/contracts", exist_ok=True) os.makedirs("/tmp/contracts", exist_ok=True)
pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf" pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf"
with open(pdf_path, "wb") as f: with open(pdf_path, "wb") as f:
@@ -283,7 +319,9 @@ class TestContractPDF:
assert "Geringwertige Wirtschaftsgüter" in html assert "Geringwertige Wirtschaftsgüter" in html
assert "§ 6 Abs. 2 EStG" in html assert "§ 6 Abs. 2 EStG" in html
def test_gwg_clause_not_in_contract_when_price_too_high(self, test_sale, test_vehicle, test_buyer): def test_gwg_clause_not_in_contract_when_price_too_high(
self, test_sale, test_vehicle, test_buyer
):
"""GwG clause does NOT appear when price > 800 even if is_gwg=true.""" """GwG clause does NOT appear when price > 800 even if is_gwg=true."""
test_sale.is_gwg = True test_sale.is_gwg = True
test_sale.sale_price = Decimal("5000.00") test_sale.sale_price = Decimal("5000.00")
@@ -293,7 +331,9 @@ class TestContractPDF:
html = build_contract_html(test_sale) html = build_contract_html(test_sale)
assert "Geringwertige Wirtschaftsgüter" not in html assert "Geringwertige Wirtschaftsgüter" not in html
def test_gwg_clause_not_in_contract_when_not_gwg(self, test_sale, test_vehicle, test_buyer): def test_gwg_clause_not_in_contract_when_not_gwg(
self, test_sale, test_vehicle, test_buyer
):
"""GwG clause does NOT appear when is_gwg=false.""" """GwG clause does NOT appear when is_gwg=false."""
test_sale.is_gwg = False test_sale.is_gwg = False
test_sale.sale_price = Decimal("500.00") test_sale.sale_price = Decimal("500.00")
@@ -303,7 +343,9 @@ class TestContractPDF:
html = build_contract_html(test_sale) html = build_contract_html(test_sale)
assert "Geringwertige Wirtschaftsgüter" not in html assert "Geringwertige Wirtschaftsgüter" not in html
def test_contract_html_contains_ust_id_field(self, test_sale, test_vehicle, test_buyer): def test_contract_html_contains_ust_id_field(
self, test_sale, test_vehicle, test_buyer
):
"""Contract HTML contains USt-IdNr. field.""" """Contract HTML contains USt-IdNr. field."""
test_sale.vehicle = test_vehicle test_sale.vehicle = test_vehicle
test_sale.buyer = test_buyer test_sale.buyer = test_buyer
@@ -318,7 +360,9 @@ class TestUstIdVerification:
async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale): async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale):
"""POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled.""" """POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled."""
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/verify-ust-id") response = await admin_client.post(
f"/api/v1/sales/{test_sale.id}/verify-ust-id"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["verified"] is False assert data["verified"] is False
+32 -12
View File
@@ -53,13 +53,16 @@ async def test_list_users_pagination(admin_client: AsyncClient, admin_user: User
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_as_admin(admin_client: AsyncClient): async def test_create_user_as_admin(admin_client: AsyncClient):
"""POST /api/v1/users as admin creates a new user, returns 201.""" """POST /api/v1/users as admin creates a new user, returns 201."""
response = await admin_client.post("/api/v1/users/", json={ response = await admin_client.post(
"/api/v1/users/",
json={
"email": "newuser@test.com", "email": "newuser@test.com",
"password": "NewUser123!", "password": "NewUser123!",
"full_name": "New User", "full_name": "New User",
"role": "verkaeufer", "role": "verkaeufer",
"language": "de", "language": "de",
}) },
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["email"] == "newuser@test.com" assert data["email"] == "newuser@test.com"
@@ -73,44 +76,55 @@ async def test_create_user_as_admin(admin_client: AsyncClient):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_as_non_admin(verkaeufer_client: AsyncClient): async def test_create_user_as_non_admin(verkaeufer_client: AsyncClient):
"""POST /api/v1/users as verkaeufer returns 403.""" """POST /api/v1/users as verkaeufer returns 403."""
response = await verkaeufer_client.post("/api/v1/users/", json={ response = await verkaeufer_client.post(
"/api/v1/users/",
json={
"email": "forbidden@test.com", "email": "forbidden@test.com",
"password": "Forbidden123!", "password": "Forbidden123!",
"full_name": "Forbidden", "full_name": "Forbidden",
"role": "admin", "role": "admin",
"language": "de", "language": "de",
}) },
)
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_duplicate_email(admin_client: AsyncClient, admin_user: User): async def test_create_user_duplicate_email(admin_client: AsyncClient, admin_user: User):
"""POST /api/v1/users with existing email returns 409.""" """POST /api/v1/users with existing email returns 409."""
response = await admin_client.post("/api/v1/users/", json={ response = await admin_client.post(
"/api/v1/users/",
json={
"email": "admin@test.com", "email": "admin@test.com",
"password": "SomePassword123!", "password": "SomePassword123!",
"full_name": "Duplicate", "full_name": "Duplicate",
"role": "verkaeufer", "role": "verkaeufer",
"language": "de", "language": "de",
}) },
)
assert response.status_code == 409 assert response.status_code == 409
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_short_password(admin_client: AsyncClient): async def test_create_user_short_password(admin_client: AsyncClient):
"""POST /api/v1/users with password < 8 chars returns 422.""" """POST /api/v1/users with password < 8 chars returns 422."""
response = await admin_client.post("/api/v1/users/", json={ response = await admin_client.post(
"/api/v1/users/",
json={
"email": "shortpw@test.com", "email": "shortpw@test.com",
"password": "short", "password": "short",
"full_name": "Short PW", "full_name": "Short PW",
"role": "verkaeufer", "role": "verkaeufer",
"language": "de", "language": "de",
}) },
)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_as_admin(admin_client: AsyncClient, admin_user: User, verkaeufer_user: User): async def test_update_user_as_admin(
admin_client: AsyncClient, admin_user: User, verkaeufer_user: User
):
"""PUT /api/v1/users/:id as admin updates user fields, returns 200.""" """PUT /api/v1/users/:id as admin updates user fields, returns 200."""
response = await admin_client.put( response = await admin_client.put(
f"/api/v1/users/{verkaeufer_user.id}", f"/api/v1/users/{verkaeufer_user.id}",
@@ -134,7 +148,9 @@ async def test_update_user_nonexistent(admin_client: AsyncClient):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_user_soft_deactivate(admin_client: AsyncClient, verkaeufer_user: User): async def test_delete_user_soft_deactivate(
admin_client: AsyncClient, verkaeufer_user: User
):
"""DELETE /api/v1/users/:id soft-deletes (is_active=false), returns 200.""" """DELETE /api/v1/users/:id soft-deletes (is_active=false), returns 200."""
response = await admin_client.delete(f"/api/v1/users/{verkaeufer_user.id}") response = await admin_client.delete(f"/api/v1/users/{verkaeufer_user.id}")
assert response.status_code == 200 assert response.status_code == 200
@@ -152,14 +168,18 @@ async def test_delete_user_nonexistent(admin_client: AsyncClient):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_user_as_non_admin(verkaeufer_client: AsyncClient, admin_user: User): async def test_delete_user_as_non_admin(
verkaeufer_client: AsyncClient, admin_user: User
):
"""DELETE /api/v1/users/:id as verkaeufer returns 403.""" """DELETE /api/v1/users/:id as verkaeufer returns 403."""
response = await verkaeufer_client.delete(f"/api/v1/users/{admin_user.id}") response = await verkaeufer_client.delete(f"/api/v1/users/{admin_user.id}")
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_response_excludes_password_hash(admin_client: AsyncClient, admin_user: User): async def test_user_response_excludes_password_hash(
admin_client: AsyncClient, admin_user: User
):
"""User response never includes password_hash or password fields.""" """User response never includes password_hash or password fields."""
response = await admin_client.get("/api/v1/users/") response = await admin_client.get("/api/v1/users/")
assert response.status_code == 200 assert response.status_code == 200
+78 -33
View File
@@ -1,17 +1,10 @@
"""Tests for vehicle CRUD endpoints and mobile.de integration.""" """Tests for vehicle CRUD endpoints and mobile.de integration."""
import uuid import uuid
from datetime import date
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.database import Base, get_db
from app.main import app
from app.models.vehicle import MobileDeListing, Vehicle
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -50,7 +43,9 @@ class TestVehicleList:
"""GET /api/v1/vehicles tests.""" """GET /api/v1/vehicles tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_returns_200_with_pagination(self, admin_client, created_vehicle): async def test_list_vehicles_returns_200_with_pagination(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles returns 200 with paginated list.""" """GET /api/v1/vehicles returns 200 with paginated list."""
response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20") response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20")
assert response.status_code == 200 assert response.status_code == 200
@@ -74,7 +69,9 @@ class TestVehicleList:
assert item["vehicle_type"] == "lkw" assert item["vehicle_type"] == "lkw"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_filter_by_availability(self, admin_client, created_vehicle): async def test_list_vehicles_filter_by_availability(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles?availability=available returns filtered results.""" """GET /api/v1/vehicles?availability=available returns filtered results."""
response = await admin_client.get("/api/v1/vehicles/?availability=available") response = await admin_client.get("/api/v1/vehicles/?availability=available")
assert response.status_code == 200 assert response.status_code == 200
@@ -92,9 +89,13 @@ class TestVehicleList:
assert data["items"][0]["created_at"] >= data["items"][1]["created_at"] assert data["items"][0]["created_at"] >= data["items"][1]["created_at"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_filter_by_price_range(self, admin_client, created_vehicle): async def test_list_vehicles_filter_by_price_range(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles?min_price=40000&max_price=50000 returns filtered results.""" """GET /api/v1/vehicles?min_price=40000&max_price=50000 returns filtered results."""
response = await admin_client.get("/api/v1/vehicles/?min_price=40000&max_price=50000") response = await admin_client.get(
"/api/v1/vehicles/?min_price=40000&max_price=50000"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
for item in data["items"]: for item in data["items"]:
@@ -123,7 +124,9 @@ class TestVehicleCreate:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data): async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data):
"""POST /api/v1/vehicles with valid data returns 201.""" """POST /api/v1/vehicles with valid data returns 201."""
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["make"] == sample_vehicle_data["make"] assert data["make"] == sample_vehicle_data["make"]
@@ -133,38 +136,58 @@ class TestVehicleCreate:
assert data["id"] is not None assert data["id"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_missing_make_returns_422(self, admin_client, sample_vehicle_data): async def test_create_vehicle_missing_make_returns_422(
self, admin_client, sample_vehicle_data
):
"""POST /api/v1/vehicles without make returns 422.""" """POST /api/v1/vehicles without make returns 422."""
del sample_vehicle_data["make"] del sample_vehicle_data["make"]
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_missing_fin_returns_422(self, admin_client, sample_vehicle_data): async def test_create_vehicle_missing_fin_returns_422(
self, admin_client, sample_vehicle_data
):
"""POST /api/v1/vehicles without fin returns 422.""" """POST /api/v1/vehicles without fin returns 422."""
del sample_vehicle_data["fin"] del sample_vehicle_data["fin"]
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_short_fin_returns_422(self, admin_client, sample_vehicle_data): async def test_create_vehicle_short_fin_returns_422(
self, admin_client, sample_vehicle_data
):
"""POST /api/v1/vehicles with short FIN returns 422.""" """POST /api/v1/vehicles with short FIN returns 422."""
sample_vehicle_data["fin"] = "SHORT" sample_vehicle_data["fin"] = "SHORT"
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_duplicate_fin_returns_409(self, admin_client, sample_vehicle_data, created_vehicle): async def test_create_vehicle_duplicate_fin_returns_409(
self, admin_client, sample_vehicle_data, created_vehicle
):
"""POST /api/v1/vehicles with duplicate FIN returns 409.""" """POST /api/v1/vehicles with duplicate FIN returns 409."""
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 409 assert response.status_code == 409
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_auto_computes_power_hp(self, admin_client, sample_vehicle_data): async def test_create_vehicle_auto_computes_power_hp(
self, admin_client, sample_vehicle_data
):
"""POST /api/v1/vehicles auto-computes power_hp from power_kw.""" """POST /api/v1/vehicles auto-computes power_hp from power_kw."""
sample_vehicle_data["power_kw"] = 100 sample_vehicle_data["power_kw"] = 100
sample_vehicle_data.pop("power_hp", None) sample_vehicle_data.pop("power_hp", None)
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136 assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136
@@ -218,7 +241,9 @@ class TestVehicleUpdate:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_vehicle_no_fields_returns_400(self, admin_client, created_vehicle): async def test_update_vehicle_no_fields_returns_400(
self, admin_client, created_vehicle
):
"""PUT /api/v1/vehicles/:id with no fields returns 400.""" """PUT /api/v1/vehicles/:id with no fields returns 400."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
response = await admin_client.put( response = await admin_client.put(
@@ -232,7 +257,9 @@ class TestVehicleDelete:
"""DELETE /api/v1/vehicles/:id tests.""" """DELETE /api/v1/vehicles/:id tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_vehicle_returns_200_with_deleted_at(self, admin_client, created_vehicle): async def test_delete_vehicle_returns_200_with_deleted_at(
self, admin_client, created_vehicle
):
"""DELETE /api/v1/vehicles/:id returns 200 and sets deleted_at.""" """DELETE /api/v1/vehicles/:id returns 200 and sets deleted_at."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}") response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
@@ -259,7 +286,9 @@ class TestVehicleDelete:
assert item["id"] != vehicle_id assert item["id"] != vehicle_id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deleted_vehicle_returns_404_on_detail(self, admin_client, created_vehicle): async def test_deleted_vehicle_returns_404_on_detail(
self, admin_client, created_vehicle
):
"""After soft-delete, GET /api/v1/vehicles/:id returns 404.""" """After soft-delete, GET /api/v1/vehicles/:id returns 404."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}") await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
@@ -274,7 +303,9 @@ class TestMobileDePush:
async def test_push_returns_202(self, admin_client, created_vehicle): async def test_push_returns_202(self, admin_client, created_vehicle):
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202.""" """POST /api/v1/vehicles/:id/mobile-de/push returns 202."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 201 mock_response.status_code = 201
mock_response.json.return_value = {"id": "mobile-de-ad-123"} mock_response.json.return_value = {"id": "mobile-de-ad-123"}
@@ -285,7 +316,9 @@ class TestMobileDePush:
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push") response = await admin_client.post(
f"/api/v1/vehicles/{vehicle_id}/mobile-de/push"
)
assert response.status_code == 202 assert response.status_code == 202
data = response.json() data = response.json()
@@ -305,20 +338,28 @@ class TestMobileDeStatus:
"""GET /api/v1/vehicles/:id/mobile-de/status tests.""" """GET /api/v1/vehicles/:id/mobile-de/status tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_returns_200_with_no_listing(self, admin_client, created_vehicle): async def test_status_returns_200_with_no_listing(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with pending status when no listing exists.""" """GET /api/v1/vehicles/:id/mobile-de/status returns 200 with pending status when no listing exists."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status") response = await admin_client.get(
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["synced"] is False assert data["synced"] is False
assert data["sync_status"] == "pending" assert data["sync_status"] == "pending"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_returns_200_with_synced_listing(self, admin_client, created_vehicle): async def test_status_returns_200_with_synced_listing(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with sync info after push.""" """GET /api/v1/vehicles/:id/mobile-de/status returns 200 with sync info after push."""
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 201 mock_response.status_code = 201
mock_response.json.return_value = {"id": "mobile-de-ad-456"} mock_response.json.return_value = {"id": "mobile-de-ad-456"}
@@ -331,7 +372,9 @@ class TestMobileDeStatus:
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push") await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status") response = await admin_client.get(
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["synced"] is True assert data["synced"] is True
@@ -342,5 +385,7 @@ class TestMobileDeStatus:
async def test_status_nonexistent_vehicle_returns_404(self, admin_client): async def test_status_nonexistent_vehicle_returns_404(self, admin_client):
"""GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404.""" """GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404."""
fake_id = str(uuid.uuid4()) fake_id = str(uuid.uuid4())
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}/mobile-de/status") response = await admin_client.get(
f"/api/v1/vehicles/{fake_id}/mobile-de/status"
)
assert response.status_code == 404 assert response.status_code == 404
+76 -26
View File
@@ -1,15 +1,14 @@
"""Additional tests for vehicle_service and router to reach 80% coverage.""" """Additional tests for vehicle_service and router to reach 80% coverage."""
import uuid import uuid
from datetime import date, datetime, timezone from datetime import date
from decimal import Decimal from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.vehicle import MobileDeListing, Vehicle from app.models.vehicle import Vehicle
from app.services import vehicle_service from app.services import vehicle_service
@@ -84,25 +83,38 @@ class TestVehicleServiceDirect:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_pagination(self, db_session): async def test_list_vehicles_pagination(self, db_session):
"""list_vehicles respects page and page_size.""" """list_vehicles respects page and page_size."""
fins = ["WDB9066351L123450", "WDB9066351L123451", "WDB9066351L123452", fins = [
"WDB9066351L123453", "WDB9066351L123454"] "WDB9066351L123450",
"WDB9066351L123451",
"WDB9066351L123452",
"WDB9066351L123453",
"WDB9066351L123454",
]
for fin in fins: for fin in fins:
data = _make_vehicle_data(fin=fin) data = _make_vehicle_data(fin=fin)
vehicle = Vehicle(**data) vehicle = Vehicle(**data)
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, page=1, page_size=2) vehicles, total = await vehicle_service.list_vehicles(
db_session, page=1, page_size=2
)
assert len(vehicles) == 2 assert len(vehicles) == 2
assert total == 5 assert total == 5
vehicles_page2, _ = await vehicle_service.list_vehicles(db_session, page=2, page_size=2) vehicles_page2, _ = await vehicle_service.list_vehicles(
db_session, page=2, page_size=2
)
assert len(vehicles_page2) == 2 assert len(vehicles_page2) == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_sort_ascending(self, db_session): async def test_list_vehicles_sort_ascending(self, db_session):
"""list_vehicles sorts ascending by make.""" """list_vehicles sorts ascending by make."""
makes_fins = [("Zebra", "WDB9066351L000001"), ("Alpha", "WDB9066351L000002"), ("Mike", "WDB9066351L000003")] makes_fins = [
("Zebra", "WDB9066351L000001"),
("Alpha", "WDB9066351L000002"),
("Mike", "WDB9066351L000003"),
]
for make, fin in makes_fins: for make, fin in makes_fins:
data = _make_vehicle_data(make=make, fin=fin) data = _make_vehicle_data(make=make, fin=fin)
vehicle = Vehicle(**data) vehicle = Vehicle(**data)
@@ -114,14 +126,18 @@ class TestVehicleServiceDirect:
assert makes == sorted(makes) assert makes == sorted(makes)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_sort_invalid_field_defaults_to_created_at(self, db_session): async def test_list_vehicles_sort_invalid_field_defaults_to_created_at(
self, db_session
):
"""list_vehicles falls back to created_at sort for invalid field.""" """list_vehicles falls back to created_at sort for invalid field."""
data = _make_vehicle_data() data = _make_vehicle_data()
vehicle = Vehicle(**data) vehicle = Vehicle(**data)
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, sort="invalid_field") vehicles, total = await vehicle_service.list_vehicles(
db_session, sort="invalid_field"
)
assert total == 1 assert total == 1
assert len(vehicles) == 1 assert len(vehicles) == 1
@@ -134,7 +150,9 @@ class TestVehicleServiceDirect:
db_session.add(Vehicle(**data2)) db_session.add(Vehicle(**data2))
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, min_price=50000) vehicles, total = await vehicle_service.list_vehicles(
db_session, min_price=50000
)
assert total == 1 assert total == 1
assert float(vehicles[0].price) >= 50000 assert float(vehicles[0].price) >= 50000
@@ -147,7 +165,9 @@ class TestVehicleServiceDirect:
db_session.add(Vehicle(**data2)) db_session.add(Vehicle(**data2))
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, max_price=40000) vehicles, total = await vehicle_service.list_vehicles(
db_session, max_price=40000
)
assert total == 1 assert total == 1
assert float(vehicles[0].price) <= 40000 assert float(vehicles[0].price) <= 40000
@@ -158,7 +178,9 @@ class TestVehicleServiceDirect:
db_session.add(Vehicle(**data)) db_session.add(Vehicle(**data))
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, search="123456") vehicles, total = await vehicle_service.list_vehicles(
db_session, search="123456"
)
assert total == 1 assert total == 1
assert "123456" in vehicles[0].fin assert "123456" in vehicles[0].fin
@@ -169,7 +191,9 @@ class TestVehicleServiceDirect:
db_session.add(Vehicle(**data)) db_session.add(Vehicle(**data))
await db_session.flush() await db_session.flush()
vehicles, total = await vehicle_service.list_vehicles(db_session, search="Munich") vehicles, total = await vehicle_service.list_vehicles(
db_session, search="Munich"
)
assert total == 1 assert total == 1
assert vehicles[0].location == "Munich" assert vehicles[0].location == "Munich"
@@ -181,14 +205,18 @@ class TestVehicleServiceDirect:
db_session.add(vehicle) db_session.add(vehicle)
await db_session.flush() await db_session.flush()
result = await vehicle_service.get_vehicle_by_fin(db_session, "WDB9066351L999999") result = await vehicle_service.get_vehicle_by_fin(
db_session, "WDB9066351L999999"
)
assert result is not None assert result is not None
assert result.fin == "WDB9066351L999999" assert result.fin == "WDB9066351L999999"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_vehicle_by_fin_not_found(self, db_session): async def test_get_vehicle_by_fin_not_found(self, db_session):
"""get_vehicle_by_fin returns None for nonexistent FIN.""" """get_vehicle_by_fin returns None for nonexistent FIN."""
result = await vehicle_service.get_vehicle_by_fin(db_session, "NONEXISTENT1234567") result = await vehicle_service.get_vehicle_by_fin(
db_session, "NONEXISTENT1234567"
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -302,14 +330,20 @@ class TestRouterAdditionalPaths:
assert data["total"] >= 1 assert data["total"] >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_vehicle_verkaeufer_allowed(self, verkaeufer_client, sample_vehicle_data): async def test_create_vehicle_verkaeufer_allowed(
self, verkaeufer_client, sample_vehicle_data
):
"""POST /api/v1/vehicles works for verkaeufer role (not admin-only).""" """POST /api/v1/vehicles works for verkaeufer role (not admin-only)."""
sample_vehicle_data["fin"] = "WDB9066351L654321" sample_vehicle_data["fin"] = "WDB9066351L654321"
response = await verkaeufer_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await verkaeufer_client.post(
"/api/v1/vehicles/", json=sample_vehicle_data
)
assert response.status_code == 201 assert response.status_code == 201
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_vehicle_fin_duplicate_returns_409(self, admin_client, sample_vehicle_data): async def test_update_vehicle_fin_duplicate_returns_409(
self, admin_client, sample_vehicle_data
):
"""PUT /api/v1/vehicles/:id with duplicate FIN returns 409.""" """PUT /api/v1/vehicles/:id with duplicate FIN returns 409."""
sample_vehicle_data["fin"] = "WDB9066351L111111" sample_vehicle_data["fin"] = "WDB9066351L111111"
resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
@@ -329,7 +363,9 @@ class TestRouterAdditionalPaths:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_vehicles_empty_result(self, admin_client): async def test_list_vehicles_empty_result(self, admin_client):
"""GET /api/v1/vehicles with filters that match nothing returns empty list.""" """GET /api/v1/vehicles with filters that match nothing returns empty list."""
response = await admin_client.get("/api/v1/vehicles/?type=baumaschine&min_price=999999") response = await admin_client.get(
"/api/v1/vehicles/?type=baumaschine&min_price=999999"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["total"] == 0 assert data["total"] == 0
@@ -342,11 +378,16 @@ class TestRouterAdditionalPaths:
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_push_to_mobile_de_failure_still_returns_202(self, admin_client, created_vehicle): async def test_push_to_mobile_de_failure_still_returns_202(
self, admin_client, created_vehicle
):
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202 even when mobile.de API fails.""" """POST /api/v1/vehicles/:id/mobile-de/push returns 202 even when mobile.de API fails."""
import httpx import httpx
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 500 mock_response.status_code = 500
mock_response.text = "Internal Server Error" mock_response.text = "Internal Server Error"
@@ -359,16 +400,23 @@ class TestRouterAdditionalPaths:
mock_client.__aexit__ = AsyncMock(return_value=None) mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push") response = await admin_client.post(
f"/api/v1/vehicles/{vehicle_id}/mobile-de/push"
)
assert response.status_code == 202 assert response.status_code == 202
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mobile_de_status_after_failed_push(self, admin_client, created_vehicle): async def test_mobile_de_status_after_failed_push(
self, admin_client, created_vehicle
):
"""GET /api/v1/vehicles/:id/mobile-de/status shows fehler after failed push.""" """GET /api/v1/vehicles/:id/mobile-de/status shows fehler after failed push."""
import httpx import httpx
vehicle_id = created_vehicle["id"] vehicle_id = created_vehicle["id"]
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls: with patch(
"app.services.mobilede_service.httpx.AsyncClient"
) as mock_client_cls:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 500 mock_response.status_code = 500
mock_response.text = "Internal Server Error" mock_response.text = "Internal Server Error"
@@ -383,7 +431,9 @@ class TestRouterAdditionalPaths:
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push") await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status") response = await admin_client.get(
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["synced"] is False assert data["synced"] is False