fix: ruff lint + format fixes in tests, ESLint fixes in frontend
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user