Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b128ea6b39 | |||
| 2cf433a01c | |||
| 74b5e6afb8 | |||
| d89304845a |
+37
-6
@@ -1,7 +1,38 @@
|
||||
# Current Status
|
||||
# Current Status – ERP Nutzfahrzeuge
|
||||
|
||||
**Phase:** Architektur (abgeschlossen)
|
||||
**Nächste Phase:** Implementation (Phase 3)
|
||||
**Status:** Bereit, wartet auf User-Freigabe
|
||||
**Nächster Task:** T01 - Core+Auth
|
||||
**Blocker:** Keine
|
||||
## Phase: Implementation (Phase 3)
|
||||
## Plan-Mode: implementation_allowed
|
||||
|
||||
## Completed Tasks
|
||||
- **T01** ✅ Auth + User Management + RBAC + Base Frontend + i18n (re-implemented, 50 backend + 16 frontend tests)
|
||||
- **T02** ✅ Vehicle Management + mobile.de Push + Vehicle UI (commit 74b5e6a)
|
||||
- **T04** ✅ Contact Management + USt-IdNr. Validation + Contact UI (commit 2cf433a)
|
||||
|
||||
## Test Results Summary
|
||||
| Task | Backend Tests | Frontend Tests | Coverage |
|
||||
|------|--------------|--------------|----------|
|
||||
| T01 | 50 passed | 16 passed | 93% (auth_service 95%, routers 98%) |
|
||||
| T02 | 73 passed | 16 passed | 82% |
|
||||
| T04 | 73 passed | 20 passed | 91% |
|
||||
|
||||
## Current Task: T03 – OCR-Erfassung via OpenRouter Vision
|
||||
- Dependencies: T01, T02 (both completed)
|
||||
- OCRResult model, ocr_service, OCR router, async processing
|
||||
- Frontend: OCR Upload (drag-and-drop), OCR Results view
|
||||
|
||||
## Git Status
|
||||
- Branch: main
|
||||
- Working tree: has uncommitted T01 changes
|
||||
|
||||
## Known Risks
|
||||
- PostgreSQL must be running for backend tests
|
||||
- Redis not installed (refresh tokens JWT-based, no async queue yet)
|
||||
- No Alembic migrations yet (tables via Base.metadata.create_all)
|
||||
- mobile.de push is synchronous (returns 202 but executes inline)
|
||||
|
||||
## Next Steps
|
||||
1. Commit T01 changes
|
||||
2. T03: OCR-Erfassung (ZB I/II) via OpenRouter Qwen2.5-VL
|
||||
3. T05: Sales + Legal/Compliance
|
||||
4. T06: KI Copilot
|
||||
5. T08: Bildretusche
|
||||
|
||||
+10
-4
@@ -1,6 +1,12 @@
|
||||
# Next Steps
|
||||
|
||||
1. User-Freigabe für Phase 3 (Implementation) einholen
|
||||
2. Plan-Mode auf implementation_allowed setzen
|
||||
3. T01 (Core+Auth) an implementation_engineer delegieren
|
||||
4. Danach: T02 (Vehicles+mobile.de)
|
||||
1. Commit T01 re-implementation changes
|
||||
2. T03: OCR-Erfassung via OpenRouter Vision (Backend + Frontend)
|
||||
3. T05: Sales + Legal/Compliance
|
||||
4. T06: KI Copilot
|
||||
5. T08: Bildretusche
|
||||
6. Alembic Migration Setup für DB Schema
|
||||
7. Docker-Compose für dev/prod erstellen
|
||||
8. Redis Integration für Token-Refresh-Storage
|
||||
9. Frontend: Dashboard-Page nach Login
|
||||
10. Frontend: User Management UI (Admin)
|
||||
|
||||
+131
@@ -12,3 +12,134 @@
|
||||
- Git: 2 Commits auf main (afe6d0a, be5a339), auf Forgejo gepusht
|
||||
- .gitignore erstellt
|
||||
- Bereit für Phase 3 (Implementation), wartet auf User-Freigabe
|
||||
|
||||
## T01 – Auth + User Management + RBAC + Frontend + i18n (2026-07-14)
|
||||
|
||||
### Backend
|
||||
- config.py: Pydantic BaseSettings, alle Env-Vars (DATABASE_URL, REDIS_URL, JWT_SECRET, CORS_ORIGINS, etc.)
|
||||
- database.py: Async SQLAlchemy engine, session factory, Base, init_db/drop_db
|
||||
- models/user.py: User mit UUID PK, email, password_hash, role (Enum), language, is_active, timestamps
|
||||
- schemas/user.py: LoginRequest, TokenResponse, RefreshRequest, UserCreate/Update/Response, UserListResponse
|
||||
- utils/jwt.py: create_access_token, create_refresh_token, decode_token, verify_access/refresh_token
|
||||
- services/auth_service.py: hash_password, verify_password, authenticate_user, generate_token_pair, refresh_access_token, create_user, list_users, update_user, deactivate_user
|
||||
- dependencies.py: get_pagination, get_current_user (JWT extraction), require_role (RBAC)
|
||||
- routers/auth.py: POST /login, POST /refresh, GET /me
|
||||
- routers/users.py: GET / (list paginated), POST / (create), PUT /:id (update), DELETE /:id (soft-delete) – alle admin-only
|
||||
- main.py: FastAPI app, CORS middleware, /api/v1 prefix, /health endpoint
|
||||
- tests/: conftest.py (async fixtures, PostgreSQL), test_auth.py (12 tests), test_users.py (14 tests), test_health.py (3 tests), test_auth_service.py (21 tests)
|
||||
- requirements.txt, .env.example, pytest.ini, .coveragerc
|
||||
|
||||
### Frontend
|
||||
- package.json, tsconfig.json, next.config.js, tailwind.config.ts, vitest.config.ts
|
||||
- app/layout.tsx, app/page.tsx, app/globals.css (design tokens als CSS vars)
|
||||
- app/(auth)/layout.tsx (I18nProvider + ToastProvider wrapper)
|
||||
- app/(auth)/login/page.tsx (Login-Form mit Validation, API call, Toast on error)
|
||||
- lib/api.ts (fetch wrapper mit auto-refresh, login, getCurrentUser, listUsers, createUser, deleteUser)
|
||||
- lib/auth.ts (useAuth hook, useRequireAuth)
|
||||
- lib/i18n.tsx (I18nProvider, useI18n, DE/EN translation)
|
||||
- components/ui/: Button, Input, Card, Table, Modal, Toast (alle 6 Komponenten)
|
||||
- messages/de.json (31 keys), messages/en.json (31 keys)
|
||||
- tests/setup.ts, tests/auth.test.tsx (5 tests), tests/i18n.test.tsx (7 tests)
|
||||
|
||||
### Test Results
|
||||
- Backend: 50/50 passed, 88% total coverage
|
||||
- Frontend: 12/12 passed, Next.js build success
|
||||
- test_report.md erstellt
|
||||
|
||||
## T02 – Vehicle Management + mobile.de Push + Vehicle UI (2026-07-14)
|
||||
|
||||
### Backend
|
||||
- models/vehicle.py: Vehicle + MobileDeListing models with UUID PK, soft-delete, all fields per spec
|
||||
- schemas/vehicle.py: VehicleCreate/Update/Response/ListResponse, MobileDeStatusResponse, MobileDePushResponse with auto-compute power_hp
|
||||
- utils/mobilede_mapping.py: map_fields() converts Vehicle to mobile.de Ad format
|
||||
- services/vehicle_service.py: CRUD with pagination, filtering, sorting, soft-delete
|
||||
- services/mobilede_service.py: push/update/delete listing, get status, retry (max 3)
|
||||
- routers/vehicles.py: 7 endpoints (list, create, detail, update, delete, mobile-de push, mobile-de status)
|
||||
- config.py: Added MOBILE_DE_API_KEY, MOBILE_DE_SELLER_ID
|
||||
- main.py: Registered vehicles router
|
||||
|
||||
### Frontend
|
||||
- lib/vehicles.ts: Full API client with typed interfaces
|
||||
- components/vehicles/: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus
|
||||
- app/[locale]/fahrzeuge/: list page, neu (create) page, [id] detail page
|
||||
- tests/vehicles.test.tsx: 16 tests
|
||||
|
||||
### Test Results
|
||||
- Backend: 73/73 pytest passed, 82% total coverage
|
||||
- Frontend: 16/16 vitest passed
|
||||
- test_report.md updated
|
||||
|
||||
---
|
||||
|
||||
## T04: Kontakt-/Kundenverwaltung + Contact UI (2026-07-14)
|
||||
|
||||
### Backend
|
||||
- models/contact.py: Contact + ContactPerson models with UUID PK, soft-delete, CHECK constraints for role/vat_id_status/country
|
||||
- schemas/contact.py: ContactCreate/Update/Response/ListResponse + ContactPersonCreate/Response with VAT ID field_validator
|
||||
- utils/ust_validation.py: DE + 10 EU country regex patterns, EU fallback, validate_vat_id, validate_vat_id_or_raise, get_country_code_from_vat_id
|
||||
- services/contact_service.py: list_contacts (search, role filter with beide inclusion, is_eu filter, is_private filter, sort, pagination), get_contact_by_id, create_contact (with nested persons), update_contact, soft_delete_contact, add_contact_person, remove_contact_person
|
||||
- routers/contacts.py: 7 endpoints (list, create, detail, update, delete, add person, remove person) with RBAC (all read, admin+verkaeufer write)
|
||||
- main.py: Registered contacts router
|
||||
|
||||
### Frontend
|
||||
- lib/contacts.ts: Full API client with typed interfaces + validateVatIdFormat frontend validation
|
||||
- components/contacts/ContactList.tsx: Table with search, role filter, EU/Inland filter, sort, pagination
|
||||
- components/contacts/ContactForm.tsx: Create/edit form with USt-IdNr. validation, EU/Inland toggle, country selector, role, legal form, address, contact info, is_private
|
||||
- components/contacts/ContactDetail.tsx: Detail view with contact persons management (add/remove via Modal)
|
||||
- app/[locale]/kontakte/: list page, neu (create) page, [id] detail page
|
||||
- tests/contacts.test.tsx: 20 tests
|
||||
|
||||
### Test Results
|
||||
- Backend: 73/73 pytest passed, 91% coverage on contact modules (service 99%, ust_validation 94%, models 93%, schemas 94%, router 67%)
|
||||
- Frontend: 20/20 vitest passed
|
||||
- test_report.md updated
|
||||
|
||||
---
|
||||
## T01 Re-Implementation (2026-07-14)
|
||||
|
||||
### Backend
|
||||
- app/config.py: Pydantic BaseSettings with DATABASE_URL, REDIS_URL, JWT_SECRET, JWT_ALGORITHM, JWT_ACCESS_TTL_MINUTES=15, JWT_REFRESH_TTL_DAYS=7, CORS_ORIGINS, UPLOAD_DIR
|
||||
- app/database.py: Async SQLAlchemy engine, session factory, Base declarative, get_db dependency, init_db/drop_db helpers
|
||||
- app/models/user.py: User model with UUID PK, email(unique), password_hash, full_name, role(enum admin/verkaeufer/buchhaltung), language(default de), is_active(default true), created_at, updated_at
|
||||
- app/schemas/user.py: LoginRequest, TokenResponse, RefreshRequest, UserBase/Create/Update/Response/ListResponse, ErrorResponse, HealthResponse
|
||||
- app/utils/jwt.py: create_access_token, create_refresh_token, decode_token, verify_access_token, verify_refresh_token (HS256, type claim separation)
|
||||
- app/services/auth_service.py: hash_password (bcrypt), verify_password, get_user_by_email/id, authenticate_user, generate_token_pair, refresh_access_token, create_user, list_users (paginated), update_user, deactivate_user (soft delete)
|
||||
- app/dependencies.py: get_pagination, get_current_user (JWT bearer extraction), require_role (RBAC factory)
|
||||
- app/routers/auth.py: POST /login, POST /refresh, GET /me
|
||||
- app/routers/users.py: GET / (admin only, paginated), POST / (admin only), PUT /:id (admin only), DELETE /:id (admin only, soft delete)
|
||||
- app/main.py: FastAPI app with CORS, /api/v1 prefix, health endpoint, router registration
|
||||
- tests/conftest.py: Function-scoped async engine, session, admin/verkaeufer/inactive user fixtures, token fixtures, HTTP client fixtures
|
||||
- tests/test_health.py: 3 tests
|
||||
- tests/test_auth.py: 12 tests (login valid/invalid/inactive/nonexistent/email-format, refresh valid/invalid/access-rejected, me with/without/invalid/refresh token)
|
||||
- tests/test_users.py: 15 tests (list admin/non-admin/no-auth, pagination, create admin/non-admin/duplicate/short-pw, update admin/nonexistent, delete soft/nonexistent/non-admin, password hash exclusion)
|
||||
- tests/test_auth_service.py: 20 direct service unit tests
|
||||
- .env.example: All required env vars documented
|
||||
- requirements.txt: fastapi, uvicorn, sqlalchemy[asyncio], asyncpg, pydantic-settings, python-jose, passlib[bcrypt], redis, python-multipart, pytest, pytest-asyncio, httpx, pytest-cov
|
||||
|
||||
### Frontend
|
||||
- package.json: Next.js 14, React 18, next-intl, Tailwind, Vitest, Testing Library
|
||||
- tsconfig.json, next.config.js, tailwind.config.ts, postcss.config.js, vitest.config.ts
|
||||
- app/globals.css: Design tokens as CSS vars (primary, secondary, background, surface, text, error, success, border)
|
||||
- app/layout.tsx: Root layout
|
||||
- app/page.tsx: Redirect to /login
|
||||
- app/(auth)/login/page.tsx: Login form with email/password, validation, Toast on error, i18n integration
|
||||
- lib/api.ts: Fetch wrapper with auth header injection, token refresh, login, getCurrentUser, listUsers, createUser, deleteUser, healthCheck
|
||||
- lib/auth.ts: useAuth hook (login, logout, fetchUser), useRequireAuth hook
|
||||
- lib/i18n.tsx: I18nProvider, useI18n hook, locale switching, parameter interpolation
|
||||
- components/ui/Button.tsx: Primary/secondary/danger/ghost variants, loading spinner
|
||||
- components/ui/Input.tsx: Label, error display, forwardRef
|
||||
- components/ui/Card.tsx: Title + children container
|
||||
- components/ui/Table.tsx: Generic table with columns + data
|
||||
- components/ui/Modal.tsx: Overlay modal with close button
|
||||
- components/ui/Toast.tsx: ToastProvider, useToast hook, success/error/info/warning types
|
||||
- messages/de.json: 28 keys (login, nav, common, user.role)
|
||||
- messages/en.json: 28 keys (matching de.json)
|
||||
- tests/setup.ts: localStorage mock, next/navigation mock
|
||||
- tests/auth.test.tsx: 10 tests (Button, Input, Card, Toast, i18n DE/EN/switch)
|
||||
- tests/i18n.test.tsx: 6 tests (key count ≥20, matching keys, translation, interpolation, fallback)
|
||||
|
||||
### Test Results
|
||||
- Backend: 50/50 pytest passed, 93% total coverage (auth_service 95%, routers 98%)
|
||||
- Frontend: 16/16 vitest passed
|
||||
- TypeScript: tsc --noEmit exit 0
|
||||
- test_report.md created
|
||||
|
||||
@@ -46,3 +46,4 @@ docker-compose.override.yml
|
||||
# Build artifacts
|
||||
*.pdf.tmp
|
||||
.cache/
|
||||
*.tsbuildinfo
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
[run]
|
||||
source = app
|
||||
|
||||
[report]
|
||||
show_missing = true
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Application configuration using Pydantic BaseSettings.
|
||||
|
||||
All secrets and environment-dependent values are loaded from environment
|
||||
variables or a local .env file. No hardcoded secrets in code.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Central application settings loaded from env vars or .env file."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
DATABASE_URL: str = (
|
||||
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||
)
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
JWT_SECRET: str = "change-me-to-a-secure-random-string"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TTL_MINUTES: int = 15
|
||||
JWT_REFRESH_TTL_DAYS: int = 7
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
|
||||
UPLOAD_DIR: str = "/tmp/uploads"
|
||||
APP_NAME: str = "ERP Nutzfahrzeuge"
|
||||
APP_ENV: str = "development"
|
||||
MOBILE_DE_API_KEY: str = ""
|
||||
MOBILE_DE_SELLER_ID: str = ""
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS comma-separated string into a list."""
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def access_token_ttl_seconds(self) -> int:
|
||||
return self.JWT_ACCESS_TTL_MINUTES * 60
|
||||
|
||||
@property
|
||||
def refresh_token_ttl_seconds(self) -> int:
|
||||
return self.JWT_REFRESH_TTL_DAYS * 86400
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Async SQLAlchemy database engine, session factory, and base model."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all SQLAlchemy models."""
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=(settings.APP_ENV == "development"),
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create all tables. Used for testing and first-run setup."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db():
|
||||
"""Drop all tables. Used in test teardown."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.auth_service import get_user_by_id
|
||||
from app.utils.jwt import verify_access_token
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> dict:
|
||||
"""Common pagination parameters."""
|
||||
return {"page": page, "page_size": page_size}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Extract and verify the JWT bearer token, returning the current user.
|
||||
|
||||
Raises 401 if token is missing, invalid, or the user is not found / inactive.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = verify_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}},
|
||||
)
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}},
|
||||
)
|
||||
|
||||
try:
|
||||
user_uuid = uuid.UUID(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_uuid)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(allowed_roles: list[str]):
|
||||
"""RBAC dependency factory: enforce that the current user has one of the allowed roles.
|
||||
|
||||
Usage:
|
||||
@router.get("/", dependencies=[Depends(require_role(["admin"]))])
|
||||
or:
|
||||
async def handler(user: User = Depends(require_role(["admin"]))):
|
||||
"""
|
||||
|
||||
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)
|
||||
if user_role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": f"Role '{user_role}' is not permitted. Required: {allowed_roles}",
|
||||
}
|
||||
},
|
||||
)
|
||||
return user
|
||||
|
||||
return role_checker
|
||||
@@ -0,0 +1,59 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Configures CORS, registers routers under /api/v1, exposes health endpoint.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, contacts, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application startup and shutdown lifecycle."""
|
||||
# Startup: could run alembic migrations here in production
|
||||
yield
|
||||
# Shutdown: cleanup resources
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="ERP Nutzfahrzeuge Backend API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS configuration from settings
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API v1 router
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(auth.router)
|
||||
api_v1_router.include_router(users.router)
|
||||
api_v1_router.include_router(vehicles.router)
|
||||
api_v1_router.include_router(contacts.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root redirect info."""
|
||||
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
|
||||
@@ -0,0 +1,200 @@
|
||||
"""SQLAlchemy models for contacts and contact persons."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ContactRole(str, enum.Enum):
|
||||
kaeufer = "kaeufer"
|
||||
verkaeufer = "verkaeufer"
|
||||
beide = "beide"
|
||||
|
||||
|
||||
class VatIdStatus(str, enum.Enum):
|
||||
ungeprueft = "ungeprueft"
|
||||
geprueft = "geprueft"
|
||||
ungueltig = "ungueltig"
|
||||
manuell_bestaetigt = "manuell_bestaetigt"
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
"""Contact entity representing a company or private contact."""
|
||||
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"role IN ('kaeufer', 'verkaeufer', 'beide')",
|
||||
name="ck_contacts_role",
|
||||
),
|
||||
CheckConstraint(
|
||||
"vat_id_status IN ('ungeprueft', 'geprueft', 'ungueltig', 'manuell_bestaetigt')",
|
||||
name="ck_contacts_vat_id_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"char_length(address_country) = 2",
|
||||
name="ck_contacts_country_alpha2",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
company_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True,
|
||||
)
|
||||
legal_form: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
address_street: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
address_zip: Mapped[str | None] = mapped_column(
|
||||
String(10), nullable=True,
|
||||
)
|
||||
address_city: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
)
|
||||
address_country: Mapped[str] = mapped_column(
|
||||
String(2), nullable=False, default="DE", index=True,
|
||||
)
|
||||
vat_id: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
website: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, index=True,
|
||||
)
|
||||
vat_id_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="ungeprueft",
|
||||
)
|
||||
is_private: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True,
|
||||
)
|
||||
|
||||
contact_persons: Mapped[list["ContactPerson"]] = relationship(
|
||||
back_populates="contact",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Contact id={self.id} company_name={self.company_name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize contact for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"company_name": self.company_name,
|
||||
"legal_form": self.legal_form,
|
||||
"address_street": self.address_street,
|
||||
"address_zip": self.address_zip,
|
||||
"address_city": self.address_city,
|
||||
"address_country": self.address_country,
|
||||
"vat_id": self.vat_id,
|
||||
"phone": self.phone,
|
||||
"email": self.email,
|
||||
"website": self.website,
|
||||
"role": self.role,
|
||||
"vat_id_status": self.vat_id_status,
|
||||
"is_private": self.is_private,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
"contact_persons": [
|
||||
p.to_dict() for p in (self.contact_persons or [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ContactPerson(Base):
|
||||
"""Contact person associated with a contact (company)."""
|
||||
|
||||
__tablename__ = "contact_persons"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("contacts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
)
|
||||
function: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactPerson id={self.id} name={self.name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize contact person for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"contact_id": str(self.contact_id),
|
||||
"name": self.name,
|
||||
"function": self.function,
|
||||
"phone": self.phone,
|
||||
"email": self.email,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SQLAlchemy User model with UUID primary key."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""User roles for RBAC."""
|
||||
admin = "admin"
|
||||
verkaeufer = "verkaeufer"
|
||||
buchhaltung = "buchhaltung"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User entity for authentication and authorization."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
)
|
||||
full_name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False,
|
||||
)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
Enum(UserRole, name="user_role"),
|
||||
nullable=False,
|
||||
default=UserRole.verkaeufer,
|
||||
)
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(5), nullable=False, default="de",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email} role={self.role}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize user for API responses (excludes password_hash)."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"full_name": self.full_name,
|
||||
"role": self.role.value if isinstance(self.role, UserRole) else self.role,
|
||||
"language": self.language,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""SQLAlchemy models for vehicles and mobile.de listings."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class VehicleCondition(str, enum.Enum):
|
||||
new = "new"
|
||||
used = "used"
|
||||
|
||||
|
||||
class VehicleAvailability(str, enum.Enum):
|
||||
available = "available"
|
||||
reserved = "reserved"
|
||||
sold = "sold"
|
||||
|
||||
|
||||
class VehicleType(str, enum.Enum):
|
||||
lkw = "lkw"
|
||||
pkw = "pkw"
|
||||
baumaschine = "baumaschine"
|
||||
stapler = "stapler"
|
||||
transporter = "transporter"
|
||||
|
||||
|
||||
class SyncStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
synced = "synced"
|
||||
fehler = "fehler"
|
||||
|
||||
|
||||
class Vehicle(Base):
|
||||
"""Vehicle entity for the ERP inventory."""
|
||||
|
||||
__tablename__ = "vehicles"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"char_length(fin) = 17", name="ck_vehicles_fin_length"
|
||||
),
|
||||
CheckConstraint(
|
||||
"condition IN ('new', 'used')", name="ck_vehicles_condition"
|
||||
),
|
||||
CheckConstraint(
|
||||
"availability IN ('available', 'reserved', 'sold')",
|
||||
name="ck_vehicles_availability",
|
||||
),
|
||||
CheckConstraint(
|
||||
"vehicle_type IN ('lkw', 'pkw', 'baumaschine', 'stapler', 'transporter')",
|
||||
name="ck_vehicles_vehicle_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"operating_hours_unit IS NULL OR operating_hours_unit IN ('h', 'min')",
|
||||
name="ck_vehicles_operating_hours_unit",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
make: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
fin: Mapped[str] = mapped_column(
|
||||
String(17), unique=True, nullable=False, index=True
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
first_registration: Mapped[date | None] = mapped_column(
|
||||
Date, nullable=True
|
||||
)
|
||||
power_kw: 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)
|
||||
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
condition: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="used"
|
||||
)
|
||||
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
availability: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="available"
|
||||
)
|
||||
price: Mapped[Decimal] = mapped_column(
|
||||
Numeric(12, 2), nullable=False
|
||||
)
|
||||
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
body_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
operating_hours: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(12, 1), nullable=True
|
||||
)
|
||||
operating_hours_unit: Mapped[str | None] = mapped_column(
|
||||
String(5), nullable=True
|
||||
)
|
||||
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
|
||||
back_populates="vehicle", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize vehicle for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"make": self.make,
|
||||
"model": self.model,
|
||||
"fin": self.fin,
|
||||
"year": self.year,
|
||||
"first_registration": (
|
||||
self.first_registration.isoformat()
|
||||
if self.first_registration
|
||||
else None
|
||||
),
|
||||
"power_kw": self.power_kw,
|
||||
"power_hp": self.power_hp,
|
||||
"fuel_type": self.fuel_type,
|
||||
"transmission": self.transmission,
|
||||
"color": self.color,
|
||||
"condition": self.condition,
|
||||
"location": self.location,
|
||||
"availability": self.availability,
|
||||
"price": float(self.price) if self.price is not None else None,
|
||||
"vehicle_type": self.vehicle_type,
|
||||
"lkw_type": self.lkw_type,
|
||||
"machine_type": self.machine_type,
|
||||
"body_type": self.body_type,
|
||||
"operating_hours": (
|
||||
float(self.operating_hours)
|
||||
if self.operating_hours is not None
|
||||
else None
|
||||
),
|
||||
"operating_hours_unit": self.operating_hours_unit,
|
||||
"mileage_km": self.mileage_km,
|
||||
"description": self.description,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class MobileDeListing(Base):
|
||||
"""mobile.de listing sync tracking for a vehicle."""
|
||||
|
||||
__tablename__ = "mobile_de_listings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vehicles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ad_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
sync_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
error_log: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
vehicle: Mapped["Vehicle"] = relationship(back_populates="mobile_de_listings")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MobileDeListing id={self.id} vehicle_id={self.vehicle_id} status={self.sync_status}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize listing for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"vehicle_id": str(self.vehicle_id),
|
||||
"ad_id": self.ad_id,
|
||||
"sync_status": self.sync_status,
|
||||
"synced_at": (
|
||||
self.synced_at.isoformat() if self.synced_at else None
|
||||
),
|
||||
"error_log": self.error_log,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Auth router: login, token refresh, current user profile."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
authenticate_user,
|
||||
generate_token_pair,
|
||||
refresh_access_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Authenticate user and return JWT access + refresh tokens."""
|
||||
user = await authenticate_user(db, body.email, body.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_CREDENTIALS",
|
||||
"message": "Invalid email or password",
|
||||
}
|
||||
},
|
||||
)
|
||||
tokens = generate_token_pair(user)
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def refresh_token(
|
||||
body: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Exchange a valid refresh token for a new access + refresh token pair."""
|
||||
tokens = await refresh_access_token(db, body.refresh_token)
|
||||
if tokens is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_REFRESH_TOKEN",
|
||||
"message": "Invalid or expired refresh token",
|
||||
}
|
||||
},
|
||||
)
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the currently authenticated user's profile."""
|
||||
return UserResponse.model_validate(current_user)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Contacts router: CRUD, search, filter, and contact person management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactListResponse,
|
||||
ContactPersonCreate,
|
||||
ContactPersonResponse,
|
||||
ContactResponse,
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.services import contact_service
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ContactListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
search: str | None = Query(None, description="Search in company_name, city, email, vat_id"),
|
||||
role: str | None = Query(None, description="Filter by role (kaeufer, verkaeufer, beide)"),
|
||||
is_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
|
||||
is_private: bool | None = Query(None, description="Filter private contacts"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List contacts with pagination, filtering, and sorting.
|
||||
|
||||
All authenticated roles can read.
|
||||
"""
|
||||
contacts, total = await contact_service.list_contacts(
|
||||
db,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
sort=sort,
|
||||
)
|
||||
return ContactListResponse(
|
||||
items=[ContactResponse.model_validate(c) for c in contacts],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
body: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Create a new contact. Requires admin or verkaeufer role."""
|
||||
data = body.model_dump(exclude_unset=False)
|
||||
contact = await contact_service.create_contact(db, data)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single contact by ID with contact persons."""
|
||||
contact = await contact_service.get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_contact(
|
||||
contact_id: uuid.UUID,
|
||||
body: ContactUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Update a contact's fields. Requires admin or verkaeufer role."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
contact = await contact_service.update_contact(db, contact_id, updates)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Soft-delete a contact (sets deleted_at). Requires admin or verkaeufer role."""
|
||||
contact = await contact_service.soft_delete_contact(db, contact_id)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{contact_id}/persons",
|
||||
response_model=ContactPersonResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_contact_person(
|
||||
contact_id: uuid.UUID,
|
||||
body: ContactPersonCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Add a contact person to an existing contact. Requires admin or verkaeufer role."""
|
||||
person = await contact_service.add_contact_person(
|
||||
db, contact_id, body.model_dump(exclude_unset=False)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactPersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{contact_id}/persons/{person_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_contact_person(
|
||||
contact_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Remove a contact person from a contact. Requires admin or verkaeufer role."""
|
||||
deleted = await contact_service.remove_contact_person(db, contact_id, person_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "PERSON_NOT_FOUND", "message": "Contact person not found"}},
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Users router: admin-only user management (list, create, update, deactivate)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
UserCreate,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
create_user as svc_create_user,
|
||||
deactivate_user as svc_deactivate_user,
|
||||
get_user_by_id,
|
||||
list_users as svc_list_users,
|
||||
update_user as svc_update_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""List all users with pagination. Admin only."""
|
||||
users, total = await svc_list_users(
|
||||
db, page=pagination["page"], page_size=pagination["page_size"]
|
||||
)
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Create a new user. Admin only."""
|
||||
try:
|
||||
user = await svc_create_user(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
role=body.role,
|
||||
language=body.language,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_EMAIL", "message": str(exc)}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Update a user's fields. Admin only."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
user = await svc_update_user(db, user_id, **updates)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_role(["admin"])),
|
||||
):
|
||||
"""Deactivate a user (soft delete). Admin only."""
|
||||
user = await svc_deactivate_user(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Vehicles router: CRUD, filtering, sorting, and mobile.de integration endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas.vehicle import (
|
||||
MobileDePushResponse,
|
||||
MobileDeStatusResponse,
|
||||
VehicleCreate,
|
||||
VehicleListResponse,
|
||||
VehicleResponse,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.services import vehicle_service
|
||||
from app.services import mobilede_service
|
||||
|
||||
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||
|
||||
|
||||
@router.get("/", response_model=VehicleListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_vehicles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
vehicle_type: str | None = Query(None, description="Filter by vehicle type"),
|
||||
availability: str | None = Query(None, description="Filter by availability"),
|
||||
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||
search: str | None = Query(None, description="Search in make, model, fin, location"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List vehicles with pagination, filtering, and sorting."""
|
||||
vehicles, total = await vehicle_service.list_vehicles(
|
||||
db,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
sort=sort,
|
||||
)
|
||||
return VehicleListResponse(
|
||||
items=[VehicleResponse.model_validate(v) for v in vehicles],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
body: VehicleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new vehicle."""
|
||||
data = body.model_dump(exclude_unset=False)
|
||||
try:
|
||||
vehicle = await vehicle_service.create_vehicle(db, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single vehicle by ID."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
body: VehicleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a vehicle's fields."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
try:
|
||||
vehicle = await vehicle_service.update_vehicle(db, vehicle_id, updates)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-delete a vehicle (sets deleted_at)."""
|
||||
vehicle = await vehicle_service.soft_delete_vehicle(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{vehicle_id}/mobile-de/push",
|
||||
response_model=MobileDePushResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def push_to_mobile_de(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Push a vehicle listing to mobile.de (async, returns 202)."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.push_listing(db, vehicle)
|
||||
|
||||
return MobileDePushResponse(
|
||||
message="Push queued",
|
||||
vehicle_id=vehicle.id,
|
||||
listing_id=listing.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vehicle_id}/mobile-de/status",
|
||||
response_model=MobileDeStatusResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_mobile_de_status(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the mobile.de sync status for a vehicle."""
|
||||
# Verify vehicle exists
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||
if listing is None:
|
||||
return MobileDeStatusResponse(
|
||||
synced=False,
|
||||
ad_id=None,
|
||||
synced_at=None,
|
||||
sync_status="pending",
|
||||
error_log=None,
|
||||
)
|
||||
|
||||
return MobileDeStatusResponse(
|
||||
synced=(listing.sync_status == "synced"),
|
||||
ad_id=listing.ad_id,
|
||||
synced_at=listing.synced_at,
|
||||
sync_status=listing.sync_status,
|
||||
error_log=listing.error_log,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Pydantic schemas for contact-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
from app.utils.ust_validation import validate_vat_id
|
||||
|
||||
|
||||
ContactRole = Literal["kaeufer", "verkaeufer", "beide"]
|
||||
VatIdStatus = Literal["ungeprueft", "geprueft", "ungueltig", "manuell_bestaetigt"]
|
||||
|
||||
|
||||
class ContactPersonBase(BaseModel):
|
||||
"""Base contact person fields."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
function: Optional[str] = Field(None, max_length=100)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class ContactPersonCreate(ContactPersonBase):
|
||||
"""POST /api/v1/contacts/:id/persons request body."""
|
||||
pass
|
||||
|
||||
|
||||
class ContactPersonResponse(ContactPersonBase):
|
||||
"""Contact person response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
contact_id: uuid.UUID
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
"""Base contact fields shared across schemas."""
|
||||
|
||||
company_name: str = Field(..., min_length=1, max_length=255)
|
||||
legal_form: Optional[str] = Field(None, max_length=50)
|
||||
address_street: Optional[str] = Field(None, max_length=255)
|
||||
address_zip: Optional[str] = Field(None, max_length=10)
|
||||
address_city: Optional[str] = Field(None, max_length=100)
|
||||
address_country: str = Field("DE", min_length=2, max_length=2)
|
||||
vat_id: Optional[str] = Field(None, max_length=20)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
role: ContactRole
|
||||
is_private: bool = False
|
||||
|
||||
@field_validator("vat_id")
|
||||
@classmethod
|
||||
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||
"""Validate VAT ID format if provided."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not validate_vat_id(v):
|
||||
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||
return v.strip().upper().replace(" ", "")
|
||||
|
||||
@field_validator("address_country")
|
||||
@classmethod
|
||||
def validate_country_code(cls, v: str) -> str:
|
||||
"""Normalise country code to uppercase."""
|
||||
return v.upper()
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""POST /api/v1/contacts request body."""
|
||||
|
||||
contact_persons: list[ContactPersonCreate] = Field(
|
||||
default_factory=list, description="Contact persons to create with the contact"
|
||||
)
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""PUT /api/v1/contacts/:id request body (all fields optional)."""
|
||||
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
legal_form: Optional[str] = Field(None, max_length=50)
|
||||
address_street: Optional[str] = Field(None, max_length=255)
|
||||
address_zip: Optional[str] = Field(None, max_length=10)
|
||||
address_city: Optional[str] = Field(None, max_length=100)
|
||||
address_country: Optional[str] = Field(None, min_length=2, max_length=2)
|
||||
vat_id: Optional[str] = Field(None, max_length=20)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
role: Optional[ContactRole] = None
|
||||
is_private: Optional[bool] = None
|
||||
|
||||
@field_validator("vat_id")
|
||||
@classmethod
|
||||
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||
"""Validate VAT ID format if provided."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not validate_vat_id(v):
|
||||
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||
return v.strip().upper().replace(" ", "")
|
||||
|
||||
@field_validator("address_country")
|
||||
@classmethod
|
||||
def validate_country_code(cls, v: str | None) -> str | None:
|
||||
"""Normalise country code to uppercase."""
|
||||
if v is not None:
|
||||
return v.upper()
|
||||
return v
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
"""Contact response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
company_name: str
|
||||
legal_form: Optional[str] = None
|
||||
address_street: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_country: str
|
||||
vat_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
role: str
|
||||
vat_id_status: str = "ungeprueft"
|
||||
is_private: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactListResponse(BaseModel):
|
||||
"""Paginated contact list response."""
|
||||
|
||||
items: list[ContactResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pydantic schemas for user-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""POST /api/v1/auth/login request body."""
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""JWT token pair returned after login or refresh."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(..., description="Access token TTL in seconds")
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""POST /api/v1/auth/refresh request body."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user fields shared across schemas."""
|
||||
email: EmailStr
|
||||
full_name: str = Field(..., min_length=1, max_length=200)
|
||||
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
|
||||
language: str = Field(default="de", max_length=5)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""POST /api/v1/users request body."""
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""PUT /api/v1/users/:id request body (all fields optional)."""
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
|
||||
language: Optional[str] = Field(None, max_length=5)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response schema (never exposes password_hash)."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
role: str
|
||||
language: str
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Paginated user list response."""
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response format."""
|
||||
error: dict
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Pydantic schemas for vehicle-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
VEHICLE_TYPES = Literal["lkw", "pkw", "baumaschine", "stapler", "transporter"]
|
||||
CONDITIONS = Literal["new", "used"]
|
||||
AVAILABILITY = Literal["available", "reserved", "sold"]
|
||||
HOURS_UNITS = Literal["h", "min"]
|
||||
|
||||
|
||||
class VehicleBase(BaseModel):
|
||||
"""Base vehicle fields shared across schemas."""
|
||||
|
||||
make: str = Field(..., min_length=1, max_length=100)
|
||||
model: str = Field(..., min_length=1, max_length=100)
|
||||
fin: str = Field(..., min_length=17, max_length=17)
|
||||
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = Field(None, ge=0)
|
||||
power_hp: Optional[int] = Field(None, ge=0)
|
||||
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||
transmission: Optional[str] = Field(None, max_length=20)
|
||||
color: Optional[str] = Field(None, max_length=50)
|
||||
condition: CONDITIONS = "used"
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
availability: AVAILABILITY = "available"
|
||||
price: Decimal = Field(..., ge=0)
|
||||
vehicle_type: VEHICLE_TYPES
|
||||
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||
machine_type: Optional[str] = Field(None, max_length=50)
|
||||
body_type: Optional[str] = Field(None, max_length=100)
|
||||
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||
mileage_km: Optional[int] = Field(None, ge=0)
|
||||
description: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_power_hp(self) -> "VehicleBase":
|
||||
"""Auto-compute power_hp from power_kw if not provided."""
|
||||
if self.power_kw is not None and self.power_hp is None:
|
||||
self.power_hp = round(self.power_kw * 1.35962)
|
||||
return self
|
||||
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
"""POST /api/v1/vehicles request body."""
|
||||
pass
|
||||
|
||||
|
||||
class VehicleUpdate(BaseModel):
|
||||
"""PUT /api/v1/vehicles/:id request body (all fields optional)."""
|
||||
|
||||
make: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
model: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
fin: Optional[str] = Field(None, min_length=17, max_length=17)
|
||||
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = Field(None, ge=0)
|
||||
power_hp: Optional[int] = Field(None, ge=0)
|
||||
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||
transmission: Optional[str] = Field(None, max_length=20)
|
||||
color: Optional[str] = Field(None, max_length=50)
|
||||
condition: Optional[CONDITIONS] = None
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
availability: Optional[AVAILABILITY] = None
|
||||
price: Optional[Decimal] = Field(None, ge=0)
|
||||
vehicle_type: Optional[VEHICLE_TYPES] = None
|
||||
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||
machine_type: Optional[str] = Field(None, max_length=50)
|
||||
body_type: Optional[str] = Field(None, max_length=100)
|
||||
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||
mileage_km: Optional[int] = Field(None, ge=0)
|
||||
description: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_power_hp(self) -> "VehicleUpdate":
|
||||
"""Auto-compute power_hp from power_kw if not provided."""
|
||||
if self.power_kw is not None and self.power_hp is None:
|
||||
self.power_hp = round(self.power_kw * 1.35962)
|
||||
return self
|
||||
|
||||
|
||||
class VehicleResponse(BaseModel):
|
||||
"""Vehicle response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
make: str
|
||||
model: str
|
||||
fin: str
|
||||
year: Optional[int] = None
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = None
|
||||
power_hp: Optional[int] = None
|
||||
fuel_type: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
condition: str
|
||||
location: Optional[str] = None
|
||||
availability: str
|
||||
price: Decimal
|
||||
vehicle_type: str
|
||||
lkw_type: Optional[str] = None
|
||||
machine_type: Optional[str] = None
|
||||
body_type: Optional[str] = None
|
||||
operating_hours: Optional[Decimal] = None
|
||||
operating_hours_unit: Optional[str] = None
|
||||
mileage_km: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class VehicleListResponse(BaseModel):
|
||||
"""Paginated vehicle list response."""
|
||||
|
||||
items: list[VehicleResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class MobileDeStatusResponse(BaseModel):
|
||||
"""mobile.de sync status for a vehicle."""
|
||||
|
||||
synced: bool
|
||||
ad_id: Optional[str] = None
|
||||
synced_at: Optional[datetime] = None
|
||||
sync_status: str = "pending"
|
||||
error_log: Optional[str] = None
|
||||
|
||||
|
||||
class MobileDePushResponse(BaseModel):
|
||||
"""Response for mobile.de push request."""
|
||||
|
||||
message: str = "Push queued"
|
||||
vehicle_id: uuid.UUID
|
||||
listing_id: Optional[uuid.UUID] = None
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Authentication service: password hashing, login, token refresh, user lookup."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_refresh_token,
|
||||
)
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a plaintext password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
return pwd_context.verify(plaintext, hashed)
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""Fetch a user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""Fetch a user by UUID."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user by email and password."""
|
||||
user = await get_user_by_email(db, email)
|
||||
if user is None:
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def generate_token_pair(user: User) -> dict:
|
||||
"""Create access + refresh JWT tokens for a given user."""
|
||||
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
access_token = create_access_token(
|
||||
user_id=str(user.id),
|
||||
role=role_val,
|
||||
email=user.email,
|
||||
lang=user.language,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=str(user.id),
|
||||
role=role_val,
|
||||
email=user.email,
|
||||
lang=user.language,
|
||||
)
|
||||
from app.config import settings
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.access_token_ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
async def refresh_access_token(db: AsyncSession, refresh_token: str) -> Optional[dict]:
|
||||
"""Verify a refresh token and issue a new token pair."""
|
||||
payload = verify_refresh_token(refresh_token)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_uuid = uuid.UUID(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, user_uuid)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
|
||||
return generate_token_pair(user)
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str,
|
||||
role: str = "verkaeufer",
|
||||
language: str = "de",
|
||||
) -> User:
|
||||
"""Create a new user with a bcrypt-hashed password."""
|
||||
existing = await get_user_by_email(db, email)
|
||||
if existing is not None:
|
||||
raise ValueError(f"User with email {email} already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
full_name=full_name,
|
||||
role=UserRole(role),
|
||||
language=language,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_users(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[User], int]:
|
||||
"""List users with pagination. Returns (users, total_count)."""
|
||||
count_result = await db.execute(select(func.count(User.id)))
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
users = list(result.scalars().all())
|
||||
return users, total
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
**updates,
|
||||
) -> Optional[User]:
|
||||
"""Update user fields. Returns updated user or None if not found."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
for field, value in updates.items():
|
||||
if field == "role" and value is not None:
|
||||
value = UserRole(value)
|
||||
if field == "password":
|
||||
user.password_hash = hash_password(value)
|
||||
elif hasattr(user, field) and value is not None:
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def deactivate_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""Soft-delete a user by setting is_active=False. Returns user or None."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
user.is_active = False
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Contact service: CRUD, search, filter, pagination, and soft-delete operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
|
||||
|
||||
_SORTABLE_FIELDS: set[str] = {
|
||||
"company_name",
|
||||
"address_city",
|
||||
"address_country",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"email",
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: select,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
is_eu: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
) -> select:
|
||||
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||
conditions = [Contact.deleted_at.is_(None)]
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Contact.company_name.ilike(search_pattern),
|
||||
Contact.address_city.ilike(search_pattern),
|
||||
Contact.email.ilike(search_pattern),
|
||||
Contact.vat_id.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# When filtering by 'kaeufer' or 'verkaeufer', include 'beide' as well
|
||||
if role in ("kaeufer", "verkaeufer"):
|
||||
conditions.append(
|
||||
or_(
|
||||
Contact.role == role,
|
||||
Contact.role == "beide",
|
||||
)
|
||||
)
|
||||
else:
|
||||
conditions.append(Contact.role == role)
|
||||
|
||||
if is_eu is not None:
|
||||
if is_eu:
|
||||
# EU = address_country != 'DE'
|
||||
conditions.append(Contact.address_country != "DE")
|
||||
else:
|
||||
# Inland = address_country == 'DE'
|
||||
conditions.append(Contact.address_country == "DE")
|
||||
|
||||
if is_private is not None:
|
||||
conditions.append(Contact.is_private == is_private)
|
||||
|
||||
return stmt.where(and_(*conditions))
|
||||
|
||||
|
||||
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||
"""Apply ORDER BY to a select statement based on sort param.
|
||||
|
||||
Format: 'field' for ascending, '-field' for descending.
|
||||
"""
|
||||
if not sort:
|
||||
return stmt.order_by(Contact.created_at.desc())
|
||||
|
||||
descending = sort.startswith("-")
|
||||
field_name = sort.lstrip("-")
|
||||
|
||||
if field_name not in _SORTABLE_FIELDS:
|
||||
return stmt.order_by(Contact.created_at.desc())
|
||||
|
||||
column = getattr(Contact, field_name)
|
||||
if descending:
|
||||
return stmt.order_by(column.desc())
|
||||
return stmt.order_by(column.asc())
|
||||
|
||||
|
||||
async def list_contacts(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
is_eu: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
sort: str | None = None,
|
||||
) -> tuple[list[Contact], int]:
|
||||
"""List contacts with pagination, filtering, and sorting.
|
||||
|
||||
Returns (contacts, total_count).
|
||||
"""
|
||||
# Build count query
|
||||
count_stmt = select(func.count(Contact.id))
|
||||
count_stmt = _apply_filters(
|
||||
count_stmt,
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Build data query with eager-loaded contact persons
|
||||
data_stmt = select(Contact).options(selectinload(Contact.contact_persons))
|
||||
data_stmt = _apply_filters(
|
||||
data_stmt,
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
)
|
||||
data_stmt = _apply_sort(data_stmt, sort)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(data_stmt)
|
||||
contacts = list(result.scalars().all())
|
||||
|
||||
return contacts, total
|
||||
|
||||
|
||||
async def get_contact_by_id(
|
||||
db: AsyncSession, contact_id: uuid.UUID
|
||||
) -> Contact | None:
|
||||
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
|
||||
stmt = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(
|
||||
and_(Contact.id == contact_id, Contact.deleted_at.is_(None))
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_contact(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Contact:
|
||||
"""Create a new contact with optional nested contact persons.
|
||||
|
||||
The data dict may contain a 'contact_persons' list of dicts.
|
||||
"""
|
||||
persons_data = data.pop("contact_persons", [])
|
||||
|
||||
contact = Contact(**data)
|
||||
db.add(contact)
|
||||
await db.flush() # Flush to generate contact.id
|
||||
|
||||
# Add contact persons if provided
|
||||
for person_data in persons_data:
|
||||
person = ContactPerson(
|
||||
contact_id=contact.id,
|
||||
name=person_data["name"],
|
||||
function=person_data.get("function"),
|
||||
phone=person_data.get("phone"),
|
||||
email=person_data.get("email"),
|
||||
)
|
||||
db.add(person)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def update_contact(
|
||||
db: AsyncSession, contact_id: uuid.UUID, updates: dict[str, Any]
|
||||
) -> Contact | None:
|
||||
"""Update a contact's fields. Returns None if not found or deleted."""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(contact, key):
|
||||
setattr(contact, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def soft_delete_contact(
|
||||
db: AsyncSession, contact_id: uuid.UUID
|
||||
) -> Contact | None:
|
||||
"""Soft-delete a contact by setting deleted_at. Returns None if not found."""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def add_contact_person(
|
||||
db: AsyncSession,
|
||||
contact_id: uuid.UUID,
|
||||
person_data: dict[str, Any],
|
||||
) -> ContactPerson | None:
|
||||
"""Add a contact person to an existing contact.
|
||||
|
||||
Returns None if the contact is not found or is deleted.
|
||||
"""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
person = ContactPerson(
|
||||
contact_id=contact_id,
|
||||
name=person_data["name"],
|
||||
function=person_data.get("function"),
|
||||
phone=person_data.get("phone"),
|
||||
email=person_data.get("email"),
|
||||
)
|
||||
db.add(person)
|
||||
await db.flush()
|
||||
await db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
async def remove_contact_person(
|
||||
db: AsyncSession,
|
||||
contact_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Remove (hard-delete) a contact person from a contact.
|
||||
|
||||
Returns True if the person was found and deleted, False otherwise.
|
||||
"""
|
||||
stmt = select(ContactPerson).where(
|
||||
and_(
|
||||
ContactPerson.id == person_id,
|
||||
ContactPerson.contact_id == contact_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
person = result.scalar_one_or_none()
|
||||
if person is None:
|
||||
return False
|
||||
|
||||
await db.delete(person)
|
||||
await db.flush()
|
||||
return True
|
||||
@@ -0,0 +1,244 @@
|
||||
"""mobile.de integration service: push, update, delete listings and check status.
|
||||
|
||||
Uses httpx for async HTTP calls to the mobile.de seller listings API.
|
||||
API key and seller ID are loaded from environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.utils.mobilede_mapping import map_fields
|
||||
|
||||
# mobile.de API base URL
|
||||
_MOBILE_DE_API_BASE = "https://api.mobile.de"
|
||||
|
||||
# Maximum retry attempts for failed pushes
|
||||
MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _get_api_headers() -> dict[str, str]:
|
||||
"""Build authorization headers for mobile.de API."""
|
||||
api_key = settings.MOBILE_DE_API_KEY
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Mobile-DE-Seller-ID": settings.MOBILE_DE_SELLER_ID,
|
||||
}
|
||||
|
||||
|
||||
def _get_listing_url(listing_id: str | None = None) -> str:
|
||||
"""Build the mobile.de listings URL."""
|
||||
base = f"{_MOBILE_DE_API_BASE}/api/seller/listings"
|
||||
if listing_id:
|
||||
return f"{base}/{listing_id}"
|
||||
return base
|
||||
|
||||
|
||||
def _get_status_url(listing_id: str) -> str:
|
||||
"""Build the mobile.de listing status URL."""
|
||||
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||
|
||||
|
||||
async def push_listing(
|
||||
db: AsyncSession, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Push a vehicle listing to mobile.de.
|
||||
|
||||
Creates a MobileDeListing record with status 'pending',
|
||||
sends the mapped ad data to mobile.de POST /api/seller/listings,
|
||||
and updates the listing with the returned ad_id and 'synced' status.
|
||||
|
||||
On failure, sets sync_status to 'fehler' with error_log.
|
||||
"""
|
||||
# Create listing record
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db.add(listing)
|
||||
await db.flush()
|
||||
|
||||
# Map vehicle fields to mobile.de ad format
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
_get_listing_url(),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
ad_id = result.get("id") or result.get("listingId")
|
||||
|
||||
listing.ad_id = str(ad_id) if ad_id else None
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def update_listing(
|
||||
db: AsyncSession, vehicle: Vehicle, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Update an existing mobile.de listing.
|
||||
|
||||
Sends PUT /api/seller/listings/{id} with updated ad data.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot update listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.put(
|
||||
_get_listing_url(listing.ad_id),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def delete_listing(
|
||||
db: AsyncSession, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Delete a listing from mobile.de.
|
||||
|
||||
Sends DELETE /api/seller/listings/{id}.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot delete listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
_get_listing_url(listing.ad_id),
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "deleted"
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def get_listing_status(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> MobileDeListing | None:
|
||||
"""Get the latest mobile.de listing status for a vehicle.
|
||||
|
||||
Returns the most recent MobileDeListing record, or None if no listing exists.
|
||||
"""
|
||||
stmt = (
|
||||
select(MobileDeListing)
|
||||
.where(MobileDeListing.vehicle_id == vehicle_id)
|
||||
.order_by(MobileDeListing.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def retry_failed_listing(
|
||||
db: AsyncSession, listing: MobileDeListing, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Retry a failed listing push.
|
||||
|
||||
Increments retry count (tracked via error_log prefix) and re-attempts push.
|
||||
After MAX_RETRIES, marks as permanently failed.
|
||||
"""
|
||||
# Count existing retries from error_log
|
||||
retry_count = 0
|
||||
if listing.error_log and listing.error_log.startswith("[retry"):
|
||||
try:
|
||||
retry_count = int(listing.error_log.split("]")[0].split("retry ")[1])
|
||||
except (IndexError, ValueError):
|
||||
retry_count = 0
|
||||
|
||||
if retry_count >= MAX_RETRIES:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"Max retries ({MAX_RETRIES}) exceeded. "
|
||||
f"Last error: {listing.error_log}"
|
||||
)
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
# Attempt re-push
|
||||
listing.sync_status = "pending"
|
||||
listing.error_log = f"[retry {retry_count + 1}] Retrying push"
|
||||
await db.flush()
|
||||
|
||||
return await push_listing(db, vehicle)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Vehicle service: CRUD, filtering, sorting, and soft-delete operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
# Fields that are safe to sort by
|
||||
_SORTABLE_FIELDS: set[str] = {
|
||||
"make",
|
||||
"model",
|
||||
"fin",
|
||||
"year",
|
||||
"price",
|
||||
"vehicle_type",
|
||||
"availability",
|
||||
"condition",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"power_kw",
|
||||
"mileage_km",
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: select,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
) -> select:
|
||||
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||
conditions = [Vehicle.deleted_at.is_(None)]
|
||||
|
||||
if vehicle_type:
|
||||
conditions.append(Vehicle.vehicle_type == vehicle_type)
|
||||
if availability:
|
||||
conditions.append(Vehicle.availability == availability)
|
||||
if min_price is not None:
|
||||
conditions.append(Vehicle.price >= min_price)
|
||||
if max_price is not None:
|
||||
conditions.append(Vehicle.price <= max_price)
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Vehicle.make.ilike(search_pattern),
|
||||
Vehicle.model.ilike(search_pattern),
|
||||
Vehicle.fin.ilike(search_pattern),
|
||||
Vehicle.location.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt.where(and_(*conditions))
|
||||
|
||||
|
||||
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||
"""Apply ORDER BY to a select statement based on sort param.
|
||||
|
||||
Format: 'field' for ascending, '-field' for descending.
|
||||
"""
|
||||
if not sort:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
descending = sort.startswith("-")
|
||||
field_name = sort.lstrip("-")
|
||||
|
||||
if field_name not in _SORTABLE_FIELDS:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
column = getattr(Vehicle, field_name)
|
||||
if descending:
|
||||
return stmt.order_by(column.desc())
|
||||
return stmt.order_by(column.asc())
|
||||
|
||||
|
||||
async def list_vehicles(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
sort: str | None = None,
|
||||
) -> tuple[list[Vehicle], int]:
|
||||
"""List vehicles with pagination, filtering, and sorting.
|
||||
|
||||
Returns (vehicles, total_count).
|
||||
"""
|
||||
# Build count query
|
||||
count_stmt = select(func.count(Vehicle.id))
|
||||
count_stmt = _apply_filters(
|
||||
count_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Build data query
|
||||
data_stmt = select(Vehicle)
|
||||
data_stmt = _apply_filters(
|
||||
data_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
data_stmt = _apply_sort(data_stmt, sort)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(data_stmt)
|
||||
vehicles = list(result.scalars().all())
|
||||
|
||||
return vehicles, total
|
||||
|
||||
|
||||
async def get_vehicle_by_id(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_vehicle_by_fin(
|
||||
db: AsyncSession, fin: str
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_vehicle(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Vehicle:
|
||||
"""Create a new vehicle.
|
||||
|
||||
Raises ValueError if FIN already exists.
|
||||
"""
|
||||
existing = await get_vehicle_by_fin(db, data["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{data['fin']}' already exists")
|
||||
|
||||
vehicle = Vehicle(**data)
|
||||
db.add(vehicle)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def update_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID, updates: dict[str, Any]
|
||||
) -> Vehicle | None:
|
||||
"""Update a vehicle's fields. Returns None if not found or deleted."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
# If FIN is being updated, check for duplicates
|
||||
if "fin" in updates and updates["fin"] != vehicle.fin:
|
||||
existing = await get_vehicle_by_fin(db, updates["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{updates['fin']}' already exists")
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(vehicle, key):
|
||||
setattr(vehicle, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def soft_delete_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Soft-delete a vehicle by setting deleted_at. Returns None if not found."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
vehicle.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
@@ -0,0 +1,83 @@
|
||||
"""JWT utility functions for token creation and verification."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
email: str,
|
||||
lang: str = "de",
|
||||
) -> str:
|
||||
"""Create a short-lived access JWT."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.access_token_ttl_seconds)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"email": email,
|
||||
"lang": lang,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
email: str,
|
||||
lang: str = "de",
|
||||
) -> str:
|
||||
"""Create a long-lived refresh JWT."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.refresh_token_ttl_seconds)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"email": email,
|
||||
"lang": lang,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and verify a JWT token. Returns payload or None."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_access_token(token: str) -> Optional[dict]:
|
||||
"""Verify an access token specifically (checks type claim)."""
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[dict]:
|
||||
"""Verify a refresh token specifically (checks type claim)."""
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
return payload
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Field mapping utilities for mobile.de listing format conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
# mobile.de category mapping based on vehicle_type
|
||||
_CATEGORY_MAP: dict[str, str] = {
|
||||
"lkw": "Truck",
|
||||
"pkw": "Car",
|
||||
"baumaschine": "ConstructionMachine",
|
||||
"stapler": "ForkliftTruck",
|
||||
"transporter": "Van",
|
||||
}
|
||||
|
||||
# LKW sub-type mapping for mobile.de category refinement
|
||||
_LKW_TYPE_MAP: dict[str, str] = {
|
||||
"sattelzugmaschine": "SemiTractor",
|
||||
"sattelauflieger": "SemiTrailer",
|
||||
"kipper": "Tipper",
|
||||
"kuehlmobil": "RefrigeratedVehicle",
|
||||
"tieflader": "LowLoader",
|
||||
"silofahrzeug": "SiloVehicle",
|
||||
"tankwagen": "TankVehicle",
|
||||
"kranwagen": "CraneVehicle",
|
||||
"muldenkipper": "DumperTruck",
|
||||
"sattelkipper": "SemiTipper",
|
||||
"schwertransporter": "HeavyTransporter",
|
||||
"sonderkonstruktion": "SpecialConstruction",
|
||||
"pritsche": "Flatbed",
|
||||
"planenlkw": "TarpaulinTruck",
|
||||
"boxenlkw": "BoxBodyTruck",
|
||||
"iso_lkw": "IsoTruck",
|
||||
}
|
||||
|
||||
|
||||
def _format_first_registration(reg_date: date | None) -> str | None:
|
||||
"""Convert date to YYYY-MM format for mobile.de."""
|
||||
if reg_date is None:
|
||||
return None
|
||||
return reg_date.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _format_mileage(vehicle: Vehicle) -> dict[str, Any] | None:
|
||||
"""Map mileage or operating hours to mobile.de mileage field."""
|
||||
if vehicle.mileage_km is not None:
|
||||
return {"value": vehicle.mileage_km, "unit": "km"}
|
||||
if vehicle.operating_hours is not None:
|
||||
unit = vehicle.operating_hours_unit or "h"
|
||||
return {"value": float(vehicle.operating_hours), "unit": unit}
|
||||
return None
|
||||
|
||||
|
||||
def _format_price(price: Decimal) -> dict[str, Any]:
|
||||
"""Map price to mobile.de price format."""
|
||||
return {
|
||||
"amount": float(price),
|
||||
"currency": "EUR",
|
||||
}
|
||||
|
||||
|
||||
def _format_power(power_kw: int | None, power_hp: int | None) -> dict[str, Any] | None:
|
||||
"""Map power to mobile.de power format."""
|
||||
if power_kw is None and power_hp is None:
|
||||
return None
|
||||
return {
|
||||
"powerKw": power_kw,
|
||||
"powerHp": power_hp,
|
||||
}
|
||||
|
||||
|
||||
def _format_category(vehicle: Vehicle) -> str:
|
||||
"""Determine mobile.de category from vehicle type and lkw_type."""
|
||||
base = _CATEGORY_MAP.get(vehicle.vehicle_type, "Other")
|
||||
if vehicle.vehicle_type == "lkw" and vehicle.lkw_type:
|
||||
# Try direct match, then strip lkw_ prefix for lookup
|
||||
lkw_key = vehicle.lkw_type
|
||||
if lkw_key in _LKW_TYPE_MAP:
|
||||
return _LKW_TYPE_MAP[lkw_key]
|
||||
stripped = lkw_key.removeprefix("lkw_")
|
||||
if stripped in _LKW_TYPE_MAP:
|
||||
return _LKW_TYPE_MAP[stripped]
|
||||
return base
|
||||
return base
|
||||
|
||||
|
||||
def map_fields(vehicle: Vehicle) -> dict[str, Any]:
|
||||
"""Map a Vehicle model to the mobile.de listing Ad format.
|
||||
|
||||
Converts internal vehicle fields to the mobile.de REST API listing format.
|
||||
See: https://developer.mobile.de/api/seller-listings
|
||||
"""
|
||||
mileage = _format_mileage(vehicle)
|
||||
power = _format_power(vehicle.power_kw, vehicle.power_hp)
|
||||
|
||||
ad: dict[str, Any] = {
|
||||
"vin": vehicle.fin,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"category": _format_category(vehicle),
|
||||
"price": _format_price(vehicle.price),
|
||||
"availabilityStatus": vehicle.availability,
|
||||
"condition": vehicle.condition,
|
||||
}
|
||||
|
||||
if vehicle.first_registration is not None:
|
||||
ad["firstRegistration"] = _format_first_registration(
|
||||
vehicle.first_registration
|
||||
)
|
||||
|
||||
if mileage is not None:
|
||||
ad["mileage"] = mileage
|
||||
|
||||
if power is not None:
|
||||
ad["power"] = power
|
||||
|
||||
if vehicle.fuel_type is not None:
|
||||
ad["fuelType"] = vehicle.fuel_type
|
||||
|
||||
if vehicle.transmission is not None:
|
||||
ad["transmission"] = vehicle.transmission
|
||||
|
||||
if vehicle.color is not None:
|
||||
ad["color"] = vehicle.color
|
||||
|
||||
if vehicle.year is not None:
|
||||
ad["year"] = vehicle.year
|
||||
|
||||
if vehicle.location is not None:
|
||||
ad["sellerLocation"] = vehicle.location
|
||||
|
||||
if vehicle.machine_type is not None:
|
||||
ad["bodyType"] = vehicle.machine_type
|
||||
elif vehicle.body_type is not None:
|
||||
ad["bodyType"] = vehicle.body_type
|
||||
|
||||
if vehicle.description is not None:
|
||||
ad["description"] = vehicle.description
|
||||
|
||||
return ad
|
||||
@@ -0,0 +1,114 @@
|
||||
"""USt-IdNr. (VAT ID) format validation for DE and common EU countries.
|
||||
|
||||
Provides basic regex-based format validation. Does NOT perform live API verification.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Country-specific VAT ID regex patterns.
|
||||
# Each pattern validates the format after the 2-letter country code prefix.
|
||||
_VAT_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
# DE: DE + 9 digits (e.g. DE123456789)
|
||||
"DE": re.compile(r"^DE\d{9}$"),
|
||||
# AT: AT + U + 8 digits (e.g. ATU12345678)
|
||||
"AT": re.compile(r"^ATU\d{8}$"),
|
||||
# FR: FR + 2 alphanumeric + 9 digits (e.g. FRAB123456789)
|
||||
"FR": re.compile(r"^FR[A-Za-z0-9]{2}\d{9}$"),
|
||||
# NL: NL + 9 digits + B + 2 digits (e.g. NL123456789B01)
|
||||
"NL": re.compile(r"^NL\d{9}B\d{2}$"),
|
||||
# PL: PL + 10 digits (e.g. PL1234567890)
|
||||
"PL": re.compile(r"^PL\d{10}$"),
|
||||
# CZ: CZ + 8-10 digits (e.g. CZ1234567890)
|
||||
"CZ": re.compile(r"^CZ\d{8,10}$"),
|
||||
# IT: IT + 11 digits (e.g. IT12345678901)
|
||||
"IT": re.compile(r"^IT\d{11}$"),
|
||||
# ES: ES + 1 alphanumeric + 7 digits + 1 alphanumeric (e.g. ESA1234567B)
|
||||
"ES": re.compile(r"^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$"),
|
||||
# BE: BE + 10 digits (e.g. BE1234567890)
|
||||
"BE": re.compile(r"^BE\d{10}$"),
|
||||
# DK: DK + 8 digits (e.g. DK12345678)
|
||||
"DK": re.compile(r"^DK\d{8}$"),
|
||||
# SE: SE + 10 digits (e.g. SE1234567890)
|
||||
"SE": re.compile(r"^SE\d{10}$"),
|
||||
}
|
||||
|
||||
# Fallback pattern for EU countries not explicitly listed above.
|
||||
# Accepts <2-letter country code> + 5-15 alphanumeric characters.
|
||||
_EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
|
||||
|
||||
# Set of supported EU country codes (ISO 3166-1 alpha-2).
|
||||
_EU_COUNTRY_CODES: set[str] = {
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR",
|
||||
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
|
||||
"SE", "SI", "SK",
|
||||
}
|
||||
|
||||
|
||||
def get_country_code_from_vat_id(vat_id: str) -> str | None:
|
||||
"""Extract the 2-letter country code from a VAT ID string.
|
||||
|
||||
Returns None if the string is too short or does not start with letters.
|
||||
"""
|
||||
if not vat_id or len(vat_id) < 3:
|
||||
return None
|
||||
prefix = vat_id[:2].upper()
|
||||
if not prefix.isalpha():
|
||||
return None
|
||||
return prefix
|
||||
|
||||
|
||||
def validate_vat_id(vat_id: str) -> bool:
|
||||
"""Validate the format of a VAT ID (USt-IdNr.).
|
||||
|
||||
Supports DE and common EU countries with specific regex patterns.
|
||||
For other EU countries, a fallback pattern is used.
|
||||
Non-EU or unrecognised formats return False.
|
||||
|
||||
Args:
|
||||
vat_id: The VAT ID string to validate (case-insensitive, will be uppercased).
|
||||
|
||||
Returns:
|
||||
True if the format is valid, False otherwise.
|
||||
"""
|
||||
if not vat_id:
|
||||
return True # Empty VAT ID is valid (nullable field)
|
||||
|
||||
normalized = vat_id.strip().upper().replace(" ", "")
|
||||
country_code = get_country_code_from_vat_id(normalized)
|
||||
if country_code is None:
|
||||
return False
|
||||
|
||||
# Check against country-specific pattern if available
|
||||
pattern = _VAT_PATTERNS.get(country_code)
|
||||
if pattern is not None:
|
||||
return bool(pattern.match(normalized))
|
||||
|
||||
# Fallback for EU countries without a specific pattern
|
||||
if country_code in _EU_COUNTRY_CODES:
|
||||
return bool(_EU_FALLBACK_PATTERN.match(normalized))
|
||||
|
||||
# Non-EU country code: reject (this module validates EU VAT IDs only)
|
||||
return False
|
||||
|
||||
|
||||
def validate_vat_id_or_raise(vat_id: str | None) -> str | None:
|
||||
"""Validate VAT ID format and return the normalised value.
|
||||
|
||||
Raises ValueError if the format is invalid.
|
||||
|
||||
Args:
|
||||
vat_id: The VAT ID string to validate, or None.
|
||||
|
||||
Returns:
|
||||
The normalised (uppercased, whitespace-stripped) VAT ID, or None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the VAT ID format is invalid.
|
||||
"""
|
||||
if vat_id is None or vat_id == "":
|
||||
return None
|
||||
|
||||
normalized = vat_id.strip().upper().replace(" ", "")
|
||||
if not validate_vat_id(normalized):
|
||||
raise ValueError(f"Invalid VAT ID format: '{vat_id}'")
|
||||
return normalized
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
redis>=5.0.0
|
||||
python-multipart>=0.0.12
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
httpx>=0.27.0
|
||||
pytest-cov>=5.0.0
|
||||
aiosqlite>=0.20.0
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Pytest fixtures for async DB testing with PostgreSQL."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.auth_service import hash_password
|
||||
|
||||
TEST_DATABASE_URL = (
|
||||
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
"""Create a fresh event loop per test for asyncpg compatibility."""
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_engine():
|
||||
"""Create a fresh async engine per test."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_session_factory(test_engine):
|
||||
"""Session factory bound to the test engine."""
|
||||
return async_sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_session_factory) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide an async DB session for tests."""
|
||||
async with test_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_user(db_session: AsyncSession) -> User:
|
||||
"""Create a test admin user."""
|
||||
user = User(
|
||||
email="admin@test.com",
|
||||
password_hash=hash_password("Admin12345!"),
|
||||
full_name="Test Admin",
|
||||
role=UserRole.admin,
|
||||
language="de",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def verkaeufer_user(db_session: AsyncSession) -> User:
|
||||
"""Create a test verkaeufer (non-admin) user."""
|
||||
user = User(
|
||||
email="verkaeufer@test.com",
|
||||
password_hash=hash_password("Verk12345!"),
|
||||
full_name="Test Verkaeufer",
|
||||
role=UserRole.verkaeufer,
|
||||
language="en",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def inactive_user(db_session: AsyncSession) -> User:
|
||||
"""Create a deactivated test user."""
|
||||
user = User(
|
||||
email="inactive@test.com",
|
||||
password_hash=hash_password("Inactive123!"),
|
||||
full_name="Test Inactive",
|
||||
role=UserRole.admin,
|
||||
language="de",
|
||||
is_active=False,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def _get_test_token(user: User) -> str:
|
||||
"""Generate an access token for a test user."""
|
||||
from app.utils.jwt import create_access_token
|
||||
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||
return create_access_token(
|
||||
user_id=str(user.id),
|
||||
role=role_val,
|
||||
email=user.email,
|
||||
lang=user.language,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(admin_user: User) -> str:
|
||||
"""Access token for admin user."""
|
||||
return _get_test_token(admin_user)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def verkaeufer_token(verkaeufer_user: User) -> str:
|
||||
"""Access token for verkaeufer user."""
|
||||
return _get_test_token(verkaeufer_user)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(test_session_factory) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Async HTTP test client with DB session override."""
|
||||
async def _override_get_db():
|
||||
async with test_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_client(client: AsyncClient, admin_token: str) -> AsyncClient:
|
||||
"""HTTP client authenticated as admin."""
|
||||
client.headers.update({"Authorization": f"Bearer {admin_token}"})
|
||||
return client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def verkaeufer_client(client: AsyncClient, verkaeufer_token: str) -> AsyncClient:
|
||||
"""HTTP client authenticated as verkaeufer (non-admin)."""
|
||||
client.headers.update({"Authorization": f"Bearer {verkaeufer_token}"})
|
||||
return client
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for auth endpoints: login, refresh, me."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.user import User
|
||||
from app.utils.jwt import create_access_token, create_refresh_token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_valid_credentials(client: AsyncClient, admin_user: User):
|
||||
"""POST /api/v1/auth/login with valid credentials returns 200 + JWT."""
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "admin@test.com",
|
||||
"password": "Admin12345!",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["expires_in"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_password(client: AsyncClient, admin_user: User):
|
||||
"""POST /api/v1/auth/login with wrong password returns 401."""
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "admin@test.com",
|
||||
"password": "WrongPassword!",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "error" in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(client: AsyncClient):
|
||||
"""POST /api/v1/auth/login with unknown email returns 401."""
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "nobody@test.com",
|
||||
"password": "SomePassword!",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_inactive_user(client: AsyncClient, inactive_user: User):
|
||||
"""POST /api/v1/auth/login with deactivated account returns 401."""
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "inactive@test.com",
|
||||
"password": "Inactive123!",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_email_format(client: AsyncClient):
|
||||
"""POST /api/v1/auth/login with invalid email format returns 422."""
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "not-an-email",
|
||||
"password": "SomePassword!",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_valid_token(client: AsyncClient, admin_user: User):
|
||||
"""POST /api/v1/auth/refresh with valid refresh token returns new tokens."""
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=str(admin_user.id),
|
||||
role="admin",
|
||||
email=admin_user.email,
|
||||
lang=admin_user.language,
|
||||
)
|
||||
response = await client.post("/api/v1/auth/refresh", json={
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_invalid_token(client: AsyncClient):
|
||||
"""POST /api/v1/auth/refresh with invalid token returns 401."""
|
||||
response = await client.post("/api/v1/auth/refresh", json={
|
||||
"refresh_token": "invalid.token.here",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_access_token_rejected(client: AsyncClient, admin_user: User):
|
||||
"""POST /api/v1/auth/refresh with an access token (not refresh) returns 401."""
|
||||
access_token = create_access_token(
|
||||
user_id=str(admin_user.id),
|
||||
role="admin",
|
||||
email=admin_user.email,
|
||||
lang=admin_user.language,
|
||||
)
|
||||
response = await client.post("/api/v1/auth/refresh", json={
|
||||
"refresh_token": access_token,
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "admin@test.com"
|
||||
assert data["role"] == "admin"
|
||||
assert data["is_active"] is True
|
||||
assert "password_hash" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_without_token(client: AsyncClient):
|
||||
"""GET /api/v1/auth/me without token returns 401."""
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_with_invalid_token(client: AsyncClient):
|
||||
"""GET /api/v1/auth/me with invalid token returns 401."""
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": "Bearer invalid.token.here"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_me_with_refresh_token(client: AsyncClient, admin_user: User):
|
||||
"""GET /api/v1/auth/me with a refresh token (not access) returns 401."""
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=str(admin_user.id),
|
||||
role="admin",
|
||||
email=admin_user.email,
|
||||
lang=admin_user.language,
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {refresh_token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Direct unit tests for auth_service functions."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.database import Base
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.auth_service import (
|
||||
authenticate_user,
|
||||
create_user,
|
||||
deactivate_user,
|
||||
generate_token_pair,
|
||||
get_user_by_email,
|
||||
get_user_by_id,
|
||||
hash_password,
|
||||
list_users,
|
||||
refresh_access_token,
|
||||
update_user,
|
||||
verify_password,
|
||||
)
|
||||
from app.utils.jwt import create_refresh_token
|
||||
|
||||
TEST_DATABASE_URL = (
|
||||
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine():
|
||||
eng = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield eng
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory(engine):
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(session_factory):
|
||||
async with session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_and_verify_password():
|
||||
hashed = hash_password("mypassword")
|
||||
assert hashed != "mypassword"
|
||||
assert verify_password("mypassword", hashed) is True
|
||||
assert verify_password("wrongpassword", hashed) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_found(session):
|
||||
user = User(
|
||||
email="findme@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name="Find Me",
|
||||
role=UserRole.admin,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
found = await get_user_by_email(session, "findme@test.com")
|
||||
assert found is not None
|
||||
assert found.email == "findme@test.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_not_found(session):
|
||||
found = await get_user_by_email(session, "nobody@test.com")
|
||||
assert found is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_found(session):
|
||||
user = User(
|
||||
email="byid@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name="By ID",
|
||||
role=UserRole.verkaeufer,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
found = await get_user_by_id(session, user.id)
|
||||
assert found is not None
|
||||
assert found.email == "byid@test.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_not_found(session):
|
||||
found = await get_user_by_id(session, uuid.uuid4())
|
||||
assert found is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_valid(session):
|
||||
user = User(
|
||||
email="auth@test.com",
|
||||
password_hash=hash_password("Auth12345!"),
|
||||
full_name="Auth User",
|
||||
role=UserRole.admin,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
result = await authenticate_user(session, "auth@test.com", "Auth12345!")
|
||||
assert result is not None
|
||||
assert result.email == "auth@test.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_wrong_password(session):
|
||||
user = User(
|
||||
email="wrongpw@test.com",
|
||||
password_hash=hash_password("Correct123!"),
|
||||
full_name="Wrong PW",
|
||||
role=UserRole.admin,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
result = await authenticate_user(session, "wrongpw@test.com", "Wrong123!")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_inactive(session):
|
||||
user = User(
|
||||
email="inactive2@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name="Inactive",
|
||||
role=UserRole.admin,
|
||||
is_active=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
result = await authenticate_user(session, "inactive2@test.com", "Test12345!")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_nonexistent(session):
|
||||
result = await authenticate_user(session, "ghost@test.com", "Test12345!")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_token_pair(session):
|
||||
user = User(
|
||||
email="token@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name="Token User",
|
||||
role=UserRole.admin,
|
||||
language="de",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
tokens = generate_token_pair(user)
|
||||
assert "access_token" in tokens
|
||||
assert "refresh_token" in tokens
|
||||
assert tokens["token_type"] == "bearer"
|
||||
assert tokens["expires_in"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_access_token_valid(session):
|
||||
user = User(
|
||||
email="refresh@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name="Refresh User",
|
||||
role=UserRole.admin,
|
||||
language="de",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
rt = create_refresh_token(
|
||||
user_id=str(user.id), role="admin", email=user.email, lang="de"
|
||||
)
|
||||
tokens = await refresh_access_token(session, rt)
|
||||
assert tokens is not None
|
||||
assert "access_token" in tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_access_token_invalid(session):
|
||||
result = await refresh_access_token(session, "invalid.token.here")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_access_token_nonexistent_user(session):
|
||||
fake_id = uuid.uuid4()
|
||||
rt = create_refresh_token(
|
||||
user_id=str(fake_id), role="admin", email="ghost@test.com", lang="de"
|
||||
)
|
||||
result = await refresh_access_token(session, rt)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_success(session):
|
||||
user = await create_user(
|
||||
session,
|
||||
email="new@test.com",
|
||||
password="NewUser123!",
|
||||
full_name="New User",
|
||||
role="verkaeufer",
|
||||
language="en",
|
||||
)
|
||||
assert user.email == "new@test.com"
|
||||
assert user.full_name == "New User"
|
||||
assert user.role == UserRole.verkaeufer
|
||||
assert user.language == "en"
|
||||
assert user.is_active is True
|
||||
assert user.password_hash != "NewUser123!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate(session):
|
||||
await create_user(
|
||||
session,
|
||||
email="dup@test.com",
|
||||
password="First123!",
|
||||
full_name="First",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await create_user(
|
||||
session,
|
||||
email="dup@test.com",
|
||||
password="Second123!",
|
||||
full_name="Second",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_pagination(session):
|
||||
for i in range(5):
|
||||
u = User(
|
||||
email=f"list{i}@test.com",
|
||||
password_hash=hash_password("Test12345!"),
|
||||
full_name=f"User {i}",
|
||||
role=UserRole.verkaeufer,
|
||||
)
|
||||
session.add(u)
|
||||
await session.commit()
|
||||
|
||||
users, total = await list_users(session, page=1, page_size=3)
|
||||
assert total == 5
|
||||
assert len(users) == 3
|
||||
|
||||
users2, total2 = await list_users(session, page=2, page_size=3)
|
||||
assert total2 == 5
|
||||
assert len(users2) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_success(session):
|
||||
user = await create_user(
|
||||
session,
|
||||
email="update@test.com",
|
||||
password="Update123!",
|
||||
full_name="Before Update",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated = await update_user(
|
||||
session, user.id, full_name="After Update", language="en"
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.full_name == "After Update"
|
||||
assert updated.language == "en"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_not_found(session):
|
||||
result = await update_user(session, uuid.uuid4(), full_name="Nobody")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role(session):
|
||||
user = await create_user(
|
||||
session,
|
||||
email="role@test.com",
|
||||
password="Role12345!",
|
||||
full_name="Role User",
|
||||
role="verkaeufer",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated = await update_user(session, user.id, role="admin")
|
||||
assert updated is not None
|
||||
assert updated.role == UserRole.admin
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_user_success(session):
|
||||
user = await create_user(
|
||||
session,
|
||||
email="deactivate@test.com",
|
||||
password="Deact123!",
|
||||
full_name="Deactivate Me",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
result = await deactivate_user(session, user.id)
|
||||
assert result is not None
|
||||
assert result.is_active is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_user_not_found(session):
|
||||
result = await deactivate_user(session, uuid.uuid4())
|
||||
assert result is None
|
||||
@@ -0,0 +1,829 @@
|
||||
"""Tests for contact CRUD, search, filter, and contact person endpoints."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.utils.ust_validation import validate_vat_id
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_contact_data():
|
||||
"""Valid contact data for creation."""
|
||||
return {
|
||||
"company_name": "Müller Transport GmbH",
|
||||
"legal_form": "GmbH",
|
||||
"address_street": "Hauptstraße 1",
|
||||
"address_zip": "10115",
|
||||
"address_city": "Berlin",
|
||||
"address_country": "DE",
|
||||
"vat_id": "DE123456789",
|
||||
"phone": "+49 30 12345678",
|
||||
"email": "info@mueller-transport.de",
|
||||
"website": "https://mueller-transport.de",
|
||||
"role": "kaeufer",
|
||||
"is_private": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_eu_contact_data():
|
||||
"""Valid EU contact data (non-DE)."""
|
||||
return {
|
||||
"company_name": "Van der Berg Logistics B.V.",
|
||||
"address_street": "Keizersgracht 100",
|
||||
"address_zip": "1015",
|
||||
"address_city": "Amsterdam",
|
||||
"address_country": "NL",
|
||||
"vat_id": "NL123456789B01",
|
||||
"email": "info@vandberg.nl",
|
||||
"role": "verkaeufer",
|
||||
"is_private": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_beide_contact_data():
|
||||
"""Valid contact with role 'beide'."""
|
||||
return {
|
||||
"company_name": "Schmidt & Söhne KG",
|
||||
"address_city": "Hamburg",
|
||||
"address_country": "DE",
|
||||
"role": "beide",
|
||||
"is_private": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def created_contact(admin_client, sample_contact_data):
|
||||
"""Create a contact via API and return the response."""
|
||||
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestUstValidation:
|
||||
"""Unit tests for USt-IdNr. validation utility."""
|
||||
|
||||
def test_validate_de_vat_id_valid(self):
|
||||
assert validate_vat_id("DE123456789") is True
|
||||
|
||||
def test_validate_de_vat_id_invalid_short(self):
|
||||
assert validate_vat_id("DE12345678") is False
|
||||
|
||||
def test_validate_de_vat_id_invalid_long(self):
|
||||
assert validate_vat_id("DE1234567890") is False
|
||||
|
||||
def test_validate_at_vat_id_valid(self):
|
||||
assert validate_vat_id("ATU12345678") is True
|
||||
|
||||
def test_validate_nl_vat_id_valid(self):
|
||||
assert validate_vat_id("NL123456789B01") is True
|
||||
|
||||
def test_validate_fr_vat_id_valid(self):
|
||||
assert validate_vat_id("FRAB123456789") is True
|
||||
|
||||
def test_validate_it_vat_id_valid(self):
|
||||
assert validate_vat_id("IT12345678901") is True
|
||||
|
||||
def test_validate_es_vat_id_valid(self):
|
||||
assert validate_vat_id("ESA1234567B") is True
|
||||
|
||||
def test_validate_empty_vat_id(self):
|
||||
assert validate_vat_id("") is True
|
||||
|
||||
def test_validate_none_vat_id(self):
|
||||
assert validate_vat_id(None) is True
|
||||
|
||||
def test_validate_non_eu_country(self):
|
||||
assert validate_vat_id("US123456789") is False
|
||||
|
||||
def test_validate_lowercase_normalised(self):
|
||||
assert validate_vat_id("de123456789") is True
|
||||
|
||||
def test_validate_with_spaces(self):
|
||||
assert validate_vat_id("DE 123 456 789") is True
|
||||
|
||||
|
||||
class TestContactList:
|
||||
"""GET /api/v1/contacts tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_returns_200_with_pagination(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts returns 200 with paginated list."""
|
||||
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=20")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_by_role_kaeufer_includes_beide(
|
||||
self, admin_client, sample_contact_data, sample_beide_contact_data
|
||||
):
|
||||
"""GET /api/v1/contacts?role=kaeufer returns kaeufer + beide contacts."""
|
||||
# Create a kaeufer contact
|
||||
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert resp1.status_code == 201
|
||||
# Create a beide contact
|
||||
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data)
|
||||
assert resp2.status_code == 201
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?role=kaeufer")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
roles = [c["role"] for c in data["items"]]
|
||||
assert "kaeufer" in roles
|
||||
assert "beide" in roles
|
||||
assert "verkaeufer" not in roles
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_by_role_verkaeufer_includes_beide(
|
||||
self, admin_client, sample_eu_contact_data, sample_beide_contact_data
|
||||
):
|
||||
"""GET /api/v1/contacts?role=verkaeufer returns verkaeufer + beide contacts."""
|
||||
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||
assert resp1.status_code == 201
|
||||
resp2 = await admin_client.post("/api/v1/contacts/", json=sample_beide_contact_data)
|
||||
assert resp2.status_code == 201
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?role=verkaeufer")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
roles = [c["role"] for c in data["items"]]
|
||||
assert "verkaeufer" in roles
|
||||
assert "beide" in roles
|
||||
assert "kaeufer" not in roles
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_is_eu_true(
|
||||
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||||
):
|
||||
"""GET /api/v1/contacts?is_eu=true returns only non-DE contacts."""
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?is_eu=true")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["address_country"] != "DE"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_is_eu_false(
|
||||
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||||
):
|
||||
"""GET /api/v1/contacts?is_eu=false returns only DE contacts."""
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?is_eu=false")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["address_country"] == "DE"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_search_by_company_name(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts?search=mueller returns matching contacts."""
|
||||
response = await admin_client.get("/api/v1/contacts/?search=mueller")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert any("Müller" in c["company_name"] for c in data["items"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_search_by_city(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts?search=berlin returns matching contacts."""
|
||||
response = await admin_client.get("/api/v1/contacts/?search=berlin")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_search_no_results(self, admin_client):
|
||||
"""GET /api/v1/contacts?search=nonexistent returns empty list."""
|
||||
response = await admin_client.get("/api/v1/contacts/?search=nonexistent_xyz")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_sort_by_company_name(self, admin_client, sample_contact_data, sample_eu_contact_data):
|
||||
"""GET /api/v1/contacts?sort=company_name returns sorted list."""
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?sort=company_name")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
names = [c["company_name"] for c in data["items"]]
|
||||
assert names == sorted(names)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_pagination(self, admin_client, sample_contact_data):
|
||||
"""GET /api/v1/contacts?page=1&page_size=1 returns correct pagination."""
|
||||
for i in range(3):
|
||||
data = {**sample_contact_data, "company_name": f"Company {i} GmbH"}
|
||||
await admin_client.post("/api/v1/contacts/", json=data)
|
||||
|
||||
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] >= 3
|
||||
|
||||
|
||||
class TestContactDetail:
|
||||
"""GET /api/v1/contacts/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_contact_returns_200_with_detail(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts/:id returns 200 with contact detail."""
|
||||
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == created_contact["id"]
|
||||
assert data["company_name"] == "Müller Transport GmbH"
|
||||
assert "contact_persons" in data
|
||||
assert isinstance(data["contact_persons"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_contact_nonexistent_returns_404(self, admin_client):
|
||||
"""GET /api/v1/contacts/:nonexistent returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.get(f"/api/v1/contacts/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["detail"]["error"]["code"] == "CONTACT_NOT_FOUND"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_contact_after_soft_delete_returns_404(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts/:id after soft-delete returns 404."""
|
||||
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert del_resp.status_code == 200
|
||||
get_resp = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
|
||||
class TestContactCreate:
|
||||
"""POST /api/v1/contacts tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_returns_201(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts with valid data returns 201."""
|
||||
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["company_name"] == sample_contact_data["company_name"]
|
||||
assert data["role"] == "kaeufer"
|
||||
assert data["vat_id_status"] == "ungeprueft"
|
||||
assert data["id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_invalid_vat_id_returns_422(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts with invalid VAT ID format returns 422."""
|
||||
data = {**sample_contact_data, "vat_id": "INVALID123"}
|
||||
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_de_vat_too_short_returns_422(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts with too-short DE VAT ID returns 422."""
|
||||
data = {**sample_contact_data, "vat_id": "DE12345678"}
|
||||
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_no_vat_id_returns_201(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts without VAT ID returns 201."""
|
||||
data = {**sample_contact_data}
|
||||
data.pop("vat_id")
|
||||
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["vat_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_contact_persons(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts with nested contact persons returns 201."""
|
||||
data = {
|
||||
**sample_contact_data,
|
||||
"contact_persons": [
|
||||
{
|
||||
"name": "Hans Müller",
|
||||
"function": "Geschäftsführer",
|
||||
"phone": "+49 30 87654321",
|
||||
"email": "hans@mueller-transport.de",
|
||||
}
|
||||
],
|
||||
}
|
||||
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||
assert response.status_code == 201
|
||||
contact_data = response.json()
|
||||
assert len(contact_data["contact_persons"]) == 1
|
||||
assert contact_data["contact_persons"][0]["name"] == "Hans Müller"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_missing_required_fields_returns_422(self, admin_client):
|
||||
"""POST /api/v1/contacts with missing required fields returns 422."""
|
||||
response = await admin_client.post("/api/v1/contacts/", json={"address_country": "DE"})
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_invalid_role_returns_422(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts with invalid role returns 422."""
|
||||
data = {**sample_contact_data, "role": "invalid_role"}
|
||||
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestContactUpdate:
|
||||
"""PUT /api/v1/contacts/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_contact_returns_200(self, admin_client, created_contact):
|
||||
"""PUT /api/v1/contacts/:id with valid data returns 200."""
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/contacts/{created_contact['id']}",
|
||||
json={"company_name": "Müller Transport AG", "phone": "+49 30 99999999"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["company_name"] == "Müller Transport AG"
|
||||
assert data["phone"] == "+49 30 99999999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_contact_nonexistent_returns_404(self, admin_client):
|
||||
"""PUT /api/v1/contacts/:nonexistent returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/contacts/{fake_id}",
|
||||
json={"company_name": "Test GmbH"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_contact_invalid_vat_id_returns_422(self, admin_client, created_contact):
|
||||
"""PUT /api/v1/contacts/:id with invalid VAT ID returns 422."""
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/contacts/{created_contact['id']}",
|
||||
json={"vat_id": "INVALID123"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_contact_no_fields_returns_400(self, admin_client, created_contact):
|
||||
"""PUT /api/v1/contacts/:id with no fields returns 400."""
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/contacts/{created_contact['id']}",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestContactDelete:
|
||||
"""DELETE /api/v1/contacts/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_contact_returns_200(self, admin_client, created_contact):
|
||||
"""DELETE /api/v1/contacts/:id returns 200 (soft delete)."""
|
||||
response = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["deleted_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_contact_nonexistent_returns_404(self, admin_client):
|
||||
"""DELETE /api/v1/contacts/:nonexistent returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.delete(f"/api/v1/contacts/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_contact_not_in_list(self, admin_client, created_contact):
|
||||
"""Soft-deleted contact does not appear in list."""
|
||||
del_resp = await admin_client.delete(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert del_resp.status_code == 200
|
||||
list_resp = await admin_client.get("/api/v1/contacts/")
|
||||
assert list_resp.status_code == 200
|
||||
ids = [c["id"] for c in list_resp.json()["items"]]
|
||||
assert created_contact["id"] not in ids
|
||||
|
||||
|
||||
class TestContactPersons:
|
||||
"""Contact person management endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_person_returns_201(self, admin_client, created_contact):
|
||||
"""POST /api/v1/contacts/:id/persons returns 201."""
|
||||
response = await admin_client.post(
|
||||
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||
json={
|
||||
"name": "Anna Schmidt",
|
||||
"function": "Einkauf",
|
||||
"phone": "+49 30 11122233",
|
||||
"email": "anna@mueller-transport.de",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Anna Schmidt"
|
||||
assert data["function"] == "Einkauf"
|
||||
assert data["id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_person_to_nonexistent_contact_returns_404(self, admin_client):
|
||||
"""POST /api/v1/contacts/:nonexistent/persons returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.post(
|
||||
f"/api/v1/contacts/{fake_id}/persons",
|
||||
json={"name": "Test Person"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_contact_person_returns_204(self, admin_client, created_contact):
|
||||
"""DELETE /api/v1/contacts/:id/persons/:person_id returns 204."""
|
||||
# First add a person
|
||||
add_resp = await admin_client.post(
|
||||
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||
json={"name": "Test Person"},
|
||||
)
|
||||
assert add_resp.status_code == 201
|
||||
person_id = add_resp.json()["id"]
|
||||
|
||||
# Then remove it
|
||||
del_resp = await admin_client.delete(
|
||||
f"/api/v1/contacts/{created_contact['id']}/persons/{person_id}"
|
||||
)
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nonexistent_contact_person_returns_404(self, admin_client, created_contact):
|
||||
"""DELETE /api/v1/contacts/:id/persons/:nonexistent returns 404."""
|
||||
fake_person_id = uuid.uuid4()
|
||||
response = await admin_client.delete(
|
||||
f"/api/v1/contacts/{created_contact['id']}/persons/{fake_person_id}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_detail_includes_persons(self, admin_client, created_contact):
|
||||
"""GET /api/v1/contacts/:id includes contact persons in response."""
|
||||
# Add a person
|
||||
await admin_client.post(
|
||||
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||
json={"name": "Max Mustermann", "function": "Vertrieb"},
|
||||
)
|
||||
# Get contact detail
|
||||
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["contact_persons"]) >= 1
|
||||
assert data["contact_persons"][0]["name"] == "Max Mustermann"
|
||||
|
||||
|
||||
class TestContactRBAC:
|
||||
"""RBAC enforcement tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_requires_auth(self, client):
|
||||
"""GET /api/v1/contacts without auth returns 401."""
|
||||
response = await client.get("/api/v1/contacts/")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_as_admin_returns_201(self, admin_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts as admin returns 201."""
|
||||
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts as verkaeufer returns 201."""
|
||||
response = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact):
|
||||
"""GET /api/v1/contacts as verkaeufer returns 200 (read allowed)."""
|
||||
response = await verkaeufer_client.get("/api/v1/contacts/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_contact_as_verkaeufer_returns_200(self, verkaeufer_client, created_contact):
|
||||
"""PUT /api/v1/contacts/:id as verkaeufer returns 200."""
|
||||
response = await verkaeufer_client.put(
|
||||
f"/api/v1/contacts/{created_contact['id']}",
|
||||
json={"company_name": "Updated by Verkaeufer"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_contact_as_verkaeufer_returns_200(self, verkaeufer_client, sample_contact_data):
|
||||
"""DELETE /api/v1/contacts/:id as verkaeufer returns 200."""
|
||||
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert resp.status_code == 201
|
||||
contact_id = resp.json()["id"]
|
||||
del_resp = await verkaeufer_client.delete(f"/api/v1/contacts/{contact_id}")
|
||||
assert del_resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_person_as_verkaeufer_returns_201(self, verkaeufer_client, sample_contact_data):
|
||||
"""POST /api/v1/contacts/:id/persons as verkaeufer returns 201."""
|
||||
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
assert resp.status_code == 201
|
||||
contact_id = resp.json()["id"]
|
||||
person_resp = await verkaeufer_client.post(
|
||||
f"/api/v1/contacts/{contact_id}/persons",
|
||||
json={"name": "Test Person"},
|
||||
)
|
||||
assert person_resp.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_person_as_verkaeufer_returns_204(self, verkaeufer_client, sample_contact_data):
|
||||
"""DELETE /api/v1/contacts/:id/persons/:pid as verkaeufer returns 204."""
|
||||
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
contact_id = resp.json()["id"]
|
||||
person_resp = await verkaeufer_client.post(
|
||||
f"/api/v1/contacts/{contact_id}/persons",
|
||||
json={"name": "To Remove"},
|
||||
)
|
||||
person_id = person_resp.json()["id"]
|
||||
del_resp = await verkaeufer_client.delete(
|
||||
f"/api/v1/contacts/{contact_id}/persons/{person_id}"
|
||||
)
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||||
"""PUT /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await verkaeufer_client.put(
|
||||
f"/api/v1/contacts/{fake_id}",
|
||||
json={"company_name": "Test"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||||
"""DELETE /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await verkaeufer_client.delete(f"/api/v1/contacts/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_person_to_nonexistent_contact_returns_404_as_verkaeufer(self, verkaeufer_client):
|
||||
"""POST /api/v1/contacts/:nonexistent/persons as verkaeufer returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await verkaeufer_client.post(
|
||||
f"/api/v1/contacts/{fake_id}/persons",
|
||||
json={"name": "Test"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nonexistent_person_returns_404_as_verkaeufer(self, verkaeufer_client, sample_contact_data):
|
||||
"""DELETE /api/v1/contacts/:id/persons/:nonexistent as verkaeufer returns 404."""
|
||||
resp = await verkaeufer_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||
contact_id = resp.json()["id"]
|
||||
fake_person_id = uuid.uuid4()
|
||||
response = await verkaeufer_client.delete(
|
||||
f"/api/v1/contacts/{contact_id}/persons/{fake_person_id}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestContactServiceDirect:
|
||||
"""Direct service-level tests for coverage."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_list_contacts_with_all_filters(self, db_session):
|
||||
"""Test list_contacts with all filter parameters."""
|
||||
from app.services import contact_service
|
||||
contact1 = Contact(
|
||||
company_name="Alpha GmbH",
|
||||
address_city="Berlin",
|
||||
address_country="DE",
|
||||
role="kaeufer",
|
||||
)
|
||||
contact2 = Contact(
|
||||
company_name="Beta B.V.",
|
||||
address_city="Amsterdam",
|
||||
address_country="NL",
|
||||
role="verkaeufer",
|
||||
)
|
||||
contact3 = Contact(
|
||||
company_name="Gamma KG",
|
||||
address_city="Hamburg",
|
||||
address_country="DE",
|
||||
role="beide",
|
||||
)
|
||||
db_session.add_all([contact1, contact2, contact3])
|
||||
await db_session.commit()
|
||||
|
||||
# Test search
|
||||
results, total = await contact_service.list_contacts(db_session, search="alpha")
|
||||
assert total == 1
|
||||
assert results[0].company_name == "Alpha GmbH"
|
||||
|
||||
# Test role filter (kaeufer includes beide)
|
||||
results, total = await contact_service.list_contacts(db_session, role="kaeufer")
|
||||
assert total == 2
|
||||
|
||||
# Test is_eu filter
|
||||
results, total = await contact_service.list_contacts(db_session, is_eu=True)
|
||||
assert total == 1
|
||||
assert results[0].address_country == "NL"
|
||||
|
||||
# Test is_eu=false (Inland)
|
||||
results, total = await contact_service.list_contacts(db_session, is_eu=False)
|
||||
assert total == 2
|
||||
|
||||
# Test is_private filter
|
||||
contact4 = Contact(
|
||||
company_name="Private Person",
|
||||
address_country="DE",
|
||||
role="kaeufer",
|
||||
is_private=True,
|
||||
)
|
||||
db_session.add(contact4)
|
||||
await db_session.commit()
|
||||
results, total = await contact_service.list_contacts(db_session, is_private=True)
|
||||
assert total == 1
|
||||
|
||||
# Test sort descending
|
||||
results, total = await contact_service.list_contacts(db_session, sort="-company_name")
|
||||
names = [r.company_name for r in results]
|
||||
assert names == sorted(names, reverse=True)
|
||||
|
||||
# Test invalid sort field falls back to created_at
|
||||
results, total = await contact_service.list_contacts(db_session, sort="invalid_field")
|
||||
assert total >= 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_get_contact_by_id_not_found(self, db_session):
|
||||
"""Test get_contact_by_id returns None for nonexistent ID."""
|
||||
from app.services import contact_service
|
||||
result = await contact_service.get_contact_by_id(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_create_contact_with_persons(self, db_session):
|
||||
"""Test create_contact with nested contact persons."""
|
||||
from app.services import contact_service
|
||||
data = {
|
||||
"company_name": "Test Service GmbH",
|
||||
"address_country": "DE",
|
||||
"role": "kaeufer",
|
||||
"contact_persons": [
|
||||
{"name": "Person 1", "function": "CEO"},
|
||||
{"name": "Person 2", "phone": "+49 30 123"},
|
||||
],
|
||||
}
|
||||
contact = await contact_service.create_contact(db_session, data)
|
||||
assert contact.company_name == "Test Service GmbH"
|
||||
assert len(contact.contact_persons) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_update_contact_not_found(self, db_session):
|
||||
"""Test update_contact returns None for nonexistent ID."""
|
||||
from app.services import contact_service
|
||||
result = await contact_service.update_contact(db_session, uuid.uuid4(), {"company_name": "Test"})
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_soft_delete_contact_not_found(self, db_session):
|
||||
"""Test soft_delete_contact returns None for nonexistent ID."""
|
||||
from app.services import contact_service
|
||||
result = await contact_service.soft_delete_contact(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_add_contact_person_not_found(self, db_session):
|
||||
"""Test add_contact_person returns None for nonexistent contact."""
|
||||
from app.services import contact_service
|
||||
result = await contact_service.add_contact_person(db_session, uuid.uuid4(), {"name": "Test"})
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_remove_contact_person_not_found(self, db_session):
|
||||
"""Test remove_contact_person returns False for nonexistent person."""
|
||||
from app.services import contact_service
|
||||
result = await contact_service.remove_contact_person(db_session, uuid.uuid4(), uuid.uuid4())
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_update_contact_success(self, db_session):
|
||||
"""Test update_contact successfully updates fields."""
|
||||
from app.services import contact_service
|
||||
contact = Contact(
|
||||
company_name="Original GmbH",
|
||||
address_country="DE",
|
||||
role="kaeufer",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
|
||||
updated = await contact_service.update_contact(db_session, contact.id, {"company_name": "Updated GmbH", "phone": "+49 30 999"})
|
||||
assert updated.company_name == "Updated GmbH"
|
||||
assert updated.phone == "+49 30 999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_soft_delete_contact_success(self, db_session):
|
||||
"""Test soft_delete_contact sets deleted_at."""
|
||||
from app.services import contact_service
|
||||
contact = Contact(
|
||||
company_name="To Delete GmbH",
|
||||
address_country="DE",
|
||||
role="kaeufer",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
|
||||
deleted = await contact_service.soft_delete_contact(db_session, contact.id)
|
||||
assert deleted.deleted_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_add_and_remove_contact_person(self, db_session):
|
||||
"""Test add_contact_person and remove_contact_person."""
|
||||
from app.services import contact_service
|
||||
contact = Contact(
|
||||
company_name="Person Test GmbH",
|
||||
address_country="DE",
|
||||
role="kaeufer",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
|
||||
person = await contact_service.add_contact_person(db_session, contact.id, {"name": "Test Person", "function": "Manager"})
|
||||
assert person is not None
|
||||
assert person.name == "Test Person"
|
||||
|
||||
removed = await contact_service.remove_contact_person(db_session, contact.id, person.id)
|
||||
assert removed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_or_raise_valid(self):
|
||||
"""Test validate_vat_id_or_raise with valid VAT ID."""
|
||||
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||
result = validate_vat_id_or_raise("DE123456789")
|
||||
assert result == "DE123456789"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_or_raise_none(self):
|
||||
"""Test validate_vat_id_or_raise with None."""
|
||||
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||
result = validate_vat_id_or_raise(None)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_or_raise_empty(self):
|
||||
"""Test validate_vat_id_or_raise with empty string."""
|
||||
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||
result = validate_vat_id_or_raise("")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_or_raise_invalid(self):
|
||||
"""Test validate_vat_id_or_raise raises ValueError for invalid format."""
|
||||
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||
with pytest.raises(ValueError, match="Invalid VAT ID format"):
|
||||
validate_vat_id_or_raise("DE123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_get_country_code(self):
|
||||
"""Test get_country_code_from_vat_id."""
|
||||
from app.utils.ust_validation import get_country_code_from_vat_id
|
||||
assert get_country_code_from_vat_id("DE123456789") == "DE"
|
||||
assert get_country_code_from_vat_id("at123") == "AT"
|
||||
assert get_country_code_from_vat_id("") is None
|
||||
assert get_country_code_from_vat_id("A") is None
|
||||
assert get_country_code_from_vat_id("12") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ust_validation_eu_fallback(self):
|
||||
"""Test EU fallback pattern for countries without specific regex."""
|
||||
from app.utils.ust_validation import validate_vat_id
|
||||
# Ireland (IE) is in EU set but has no specific pattern
|
||||
assert validate_vat_id("IE1234567AB") is True
|
||||
# Bulgaria (BG) is in EU set but has no specific pattern
|
||||
assert validate_vat_id("BG1234567890") is True
|
||||
# Too short for fallback
|
||||
assert validate_vat_id("BG123") is False
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tests for the health check endpoint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(client: AsyncClient):
|
||||
"""GET /api/v1/health returns 200 with status ok."""
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_no_auth_required(client: AsyncClient):
|
||||
"""Health endpoint works without authentication."""
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_endpoint(client: AsyncClient):
|
||||
"""Root endpoint returns app info."""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "name" in data
|
||||
assert "version" in data
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Tests for mobile.de service: field mapping, push, update, delete, status, retry."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.services import mobilede_service
|
||||
from app.utils.mobilede_mapping import map_fields
|
||||
|
||||
|
||||
def _make_vehicle(**overrides) -> Vehicle:
|
||||
"""Create a Vehicle instance with defaults and optional overrides."""
|
||||
defaults = {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": date(2020, 3, 15),
|
||||
"power_kw": 300,
|
||||
"power_hp": 408,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": Decimal("45000.00"),
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
vehicle = Vehicle(**defaults)
|
||||
vehicle.id = uuid.uuid4()
|
||||
return vehicle
|
||||
|
||||
|
||||
class TestFieldMapping:
|
||||
"""Tests for mobile.de field mapping (map_fields)."""
|
||||
|
||||
def test_map_fields_basic_lkw(self):
|
||||
"""map_fields produces correct ad format for LKW."""
|
||||
vehicle = _make_vehicle()
|
||||
ad = map_fields(vehicle)
|
||||
|
||||
assert ad["vin"] == "WDB9066351L123456"
|
||||
assert ad["make"] == "Mercedes-Benz"
|
||||
assert ad["model"] == "Actros"
|
||||
assert ad["category"] == "SemiTractor"
|
||||
assert ad["price"] == {"amount": 45000.0, "currency": "EUR"}
|
||||
assert ad["availabilityStatus"] == "available"
|
||||
assert ad["condition"] == "used"
|
||||
assert ad["firstRegistration"] == "2020-03"
|
||||
assert ad["mileage"] == {"value": 120000, "unit": "km"}
|
||||
assert ad["power"] == {"powerKw": 300, "powerHp": 408}
|
||||
assert ad["fuelType"] == "Diesel"
|
||||
assert ad["transmission"] == "Manual"
|
||||
assert ad["color"] == "White"
|
||||
assert ad["sellerLocation"] == "Berlin"
|
||||
assert ad["description"] == "Well maintained truck"
|
||||
|
||||
def test_map_fields_baumaschine_with_operating_hours(self):
|
||||
"""map_fields maps operating_hours to mileage for baumaschine."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="baumaschine",
|
||||
machine_type="Bagger",
|
||||
operating_hours=Decimal("3500.5"),
|
||||
operating_hours_unit="h",
|
||||
mileage_km=None,
|
||||
lkw_type=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
|
||||
assert ad["category"] == "ConstructionMachine"
|
||||
assert ad["mileage"] == {"value": 3500.5, "unit": "h"}
|
||||
assert ad["bodyType"] == "Bagger"
|
||||
|
||||
def test_map_fields_pkw(self):
|
||||
"""map_fields maps PKW correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="pkw",
|
||||
lkw_type=None,
|
||||
body_type="Limousine",
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Car"
|
||||
assert ad["bodyType"] == "Limousine"
|
||||
|
||||
def test_map_fields_stapler(self):
|
||||
"""map_fields maps Stapler correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="stapler",
|
||||
lkw_type=None,
|
||||
operating_hours=Decimal("12000"),
|
||||
operating_hours_unit="h",
|
||||
mileage_km=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "ForkliftTruck"
|
||||
assert ad["mileage"] == {"value": 12000.0, "unit": "h"}
|
||||
|
||||
def test_map_fields_transporter(self):
|
||||
"""map_fields maps Transporter correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="transporter",
|
||||
lkw_type=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Van"
|
||||
|
||||
def test_map_fields_no_optional_fields(self):
|
||||
"""map_fields handles vehicle with no optional fields."""
|
||||
vehicle = Vehicle(
|
||||
make="Test",
|
||||
model="Model",
|
||||
fin="WDB9066351L123456",
|
||||
condition="new",
|
||||
availability="available",
|
||||
price=Decimal("10000.00"),
|
||||
vehicle_type="pkw",
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["vin"] == "WDB9066351L123456"
|
||||
assert ad["make"] == "Test"
|
||||
assert "firstRegistration" not in ad
|
||||
assert "mileage" not in ad
|
||||
assert "power" not in ad
|
||||
assert "fuelType" not in ad
|
||||
|
||||
def test_map_fields_lkw_type_with_prefix(self):
|
||||
"""map_fields strips lkw_ prefix for category lookup."""
|
||||
vehicle = _make_vehicle(lkw_type="lkw_kipper")
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Tipper"
|
||||
|
||||
def test_map_fields_lkw_type_unknown_falls_back(self):
|
||||
"""map_fields falls back to base category for unknown lkw_type."""
|
||||
vehicle = _make_vehicle(lkw_type="unknown_type")
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Truck"
|
||||
|
||||
|
||||
class TestPushListing:
|
||||
"""Tests for mobilede_service.push_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_success(self, db_session):
|
||||
"""push_listing creates listing with synced status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "ad-123"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "synced"
|
||||
assert listing.ad_id == "ad-123"
|
||||
assert listing.synced_at is not None
|
||||
assert listing.error_log is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_http_error(self, db_session):
|
||||
"""push_listing sets fehler status on HTTP error."""
|
||||
import httpx
|
||||
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Bad Request", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "fehler"
|
||||
assert listing.error_log is not None
|
||||
assert "400" in listing.error_log
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_request_error(self, db_session):
|
||||
"""push_listing sets fehler status on request error."""
|
||||
import httpx
|
||||
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "fehler"
|
||||
assert "Connection refused" in listing.error_log
|
||||
|
||||
|
||||
class TestUpdateListing:
|
||||
"""Tests for mobilede_service.update_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listing_success(self, db_session):
|
||||
"""update_listing updates synced status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-123",
|
||||
sync_status="synced",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||
|
||||
assert result.sync_status == "synced"
|
||||
assert result.synced_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listing_no_ad_id(self, db_session):
|
||||
"""update_listing sets fehler when listing has no ad_id."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "ad_id" in result.error_log
|
||||
|
||||
|
||||
class TestDeleteListing:
|
||||
"""Tests for mobilede_service.delete_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_listing_success(self, db_session):
|
||||
"""delete_listing sets deleted status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-123",
|
||||
sync_status="synced",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 204
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.delete = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.delete_listing(db_session, listing)
|
||||
|
||||
assert result.sync_status == "deleted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_listing_no_ad_id(self, db_session):
|
||||
"""delete_listing sets fehler when listing has no ad_id."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.delete_listing(db_session, listing)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "ad_id" in result.error_log
|
||||
|
||||
|
||||
class TestGetListingStatus:
|
||||
"""Tests for mobilede_service.get_listing_status."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_status_returns_latest(self, db_session):
|
||||
"""get_listing_status returns the most recent listing."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
listing1 = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log="First attempt failed",
|
||||
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
db_session.add(listing1)
|
||||
await db_session.flush()
|
||||
|
||||
listing2 = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-456",
|
||||
sync_status="synced",
|
||||
created_at=datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
db_session.add(listing2)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.get_listing_status(db_session, vehicle.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.sync_status == "synced"
|
||||
assert result.ad_id == "ad-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_status_returns_none_when_no_listing(self, db_session):
|
||||
"""get_listing_status returns None when no listing exists."""
|
||||
vehicle_id = uuid.uuid4()
|
||||
result = await mobilede_service.get_listing_status(db_session, vehicle_id)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRetryFailedListing:
|
||||
"""Tests for mobilede_service.retry_failed_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_succeeds_within_max_retries(self, db_session):
|
||||
"""retry_failed_listing re-attempts push when under max retries."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log="[retry 1] HTTP 500: Internal Server Error",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "ad-789"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||
|
||||
assert result.sync_status == "synced"
|
||||
assert result.ad_id == "ad-789"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_exceeds_max_retries(self, db_session):
|
||||
"""retry_failed_listing marks as permanently failed after max retries."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log=f"[retry {mobilede_service.MAX_RETRIES}] Last error",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "Max retries" in result.error_log
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for users endpoints: list, create, update, delete (admin only)."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_as_admin(admin_client: AsyncClient, admin_user: User):
|
||||
"""GET /api/v1/users as admin returns 200 + paginated list."""
|
||||
response = await admin_client.get("/api/v1/users/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert data["total"] >= 1
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_as_non_admin(verkaeufer_client: AsyncClient):
|
||||
"""GET /api/v1/users as verkaeufer returns 403."""
|
||||
response = await verkaeufer_client.get("/api/v1/users/")
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert "error" in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_without_auth(client: AsyncClient):
|
||||
"""GET /api/v1/users without token returns 401."""
|
||||
response = await client.get("/api/v1/users/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_pagination(admin_client: AsyncClient, admin_user: User):
|
||||
"""GET /api/v1/users?page=1&page_size=5 returns correct pagination."""
|
||||
response = await admin_client.get("/api/v1/users/?page=1&page_size=5")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 5
|
||||
assert len(data["items"]) <= 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_as_admin(admin_client: AsyncClient):
|
||||
"""POST /api/v1/users as admin creates a new user, returns 201."""
|
||||
response = await admin_client.post("/api/v1/users/", json={
|
||||
"email": "newuser@test.com",
|
||||
"password": "NewUser123!",
|
||||
"full_name": "New User",
|
||||
"role": "verkaeufer",
|
||||
"language": "de",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "newuser@test.com"
|
||||
assert data["full_name"] == "New User"
|
||||
assert data["role"] == "verkaeufer"
|
||||
assert data["is_active"] is True
|
||||
assert "password_hash" not in data
|
||||
assert "password" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_as_non_admin(verkaeufer_client: AsyncClient):
|
||||
"""POST /api/v1/users as verkaeufer returns 403."""
|
||||
response = await verkaeufer_client.post("/api/v1/users/", json={
|
||||
"email": "forbidden@test.com",
|
||||
"password": "Forbidden123!",
|
||||
"full_name": "Forbidden",
|
||||
"role": "admin",
|
||||
"language": "de",
|
||||
})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate_email(admin_client: AsyncClient, admin_user: User):
|
||||
"""POST /api/v1/users with existing email returns 409."""
|
||||
response = await admin_client.post("/api/v1/users/", json={
|
||||
"email": "admin@test.com",
|
||||
"password": "SomePassword123!",
|
||||
"full_name": "Duplicate",
|
||||
"role": "verkaeufer",
|
||||
"language": "de",
|
||||
})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_short_password(admin_client: AsyncClient):
|
||||
"""POST /api/v1/users with password < 8 chars returns 422."""
|
||||
response = await admin_client.post("/api/v1/users/", json={
|
||||
"email": "shortpw@test.com",
|
||||
"password": "short",
|
||||
"full_name": "Short PW",
|
||||
"role": "verkaeufer",
|
||||
"language": "de",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/users/{verkaeufer_user.id}",
|
||||
json={"full_name": "Updated Name", "language": "de"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["full_name"] == "Updated Name"
|
||||
assert data["language"] == "de"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_nonexistent(admin_client: AsyncClient):
|
||||
"""PUT /api/v1/users/:id with unknown id returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/users/{fake_id}",
|
||||
json={"full_name": "Nobody"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
response = await admin_client.delete(f"/api/v1/users/{verkaeufer_user.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_active"] is False
|
||||
assert data["id"] == str(verkaeufer_user.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_nonexistent(admin_client: AsyncClient):
|
||||
"""DELETE /api/v1/users/:id with unknown id returns 404."""
|
||||
fake_id = uuid.uuid4()
|
||||
response = await admin_client.delete(f"/api/v1/users/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_as_non_admin(verkaeufer_client: AsyncClient, admin_user: User):
|
||||
"""DELETE /api/v1/users/:id as verkaeufer returns 403."""
|
||||
response = await verkaeufer_client.delete(f"/api/v1/users/{admin_user.id}")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_response_excludes_password_hash(admin_client: AsyncClient, admin_user: User):
|
||||
"""User response never includes password_hash or password fields."""
|
||||
response = await admin_client.get("/api/v1/users/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert "password_hash" not in item
|
||||
assert "password" not in item
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Tests for vehicle CRUD endpoints and mobile.de integration."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_vehicle_data():
|
||||
"""Valid vehicle data for creation."""
|
||||
return {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": "2020-03-15",
|
||||
"power_kw": 300,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": 45000.00,
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||
"""Create a vehicle via API and return the response."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestVehicleList:
|
||||
"""GET /api/v1/vehicles tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_returns_200_with_pagination(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles returns 200 with paginated list."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_type(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?type=lkw returns filtered results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?type=lkw")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["vehicle_type"] == "lkw"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_availability(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?availability=available returns filtered results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?availability=available")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["availability"] == "available"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_sort_descending(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?sort=-created_at returns sorted results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?sort=-created_at")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
if len(data["items"]) >= 2:
|
||||
assert data["items"][0]["created_at"] >= data["items"][1]["created_at"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?min_price=40000&max_price=50000")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert float(item["price"]) >= 40000
|
||||
assert float(item["price"]) <= 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?search=Mercedes returns matching results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?search=Mercedes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert "Mercedes" in item["make"] or "Mercedes" in item["model"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_requires_auth(self, client):
|
||||
"""GET /api/v1/vehicles without auth returns 401."""
|
||||
response = await client.get("/api/v1/vehicles/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestVehicleCreate:
|
||||
"""POST /api/v1/vehicles tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles with valid data returns 201."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["make"] == sample_vehicle_data["make"]
|
||||
assert data["model"] == sample_vehicle_data["model"]
|
||||
assert data["fin"] == sample_vehicle_data["fin"]
|
||||
assert data["vehicle_type"] == "lkw"
|
||||
assert data["id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_missing_make_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles without make returns 422."""
|
||||
del sample_vehicle_data["make"]
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_missing_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles without fin returns 422."""
|
||||
del sample_vehicle_data["fin"]
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_short_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles with short FIN returns 422."""
|
||||
sample_vehicle_data["fin"] = "SHORT"
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
sample_vehicle_data["power_kw"] = 100
|
||||
sample_vehicle_data.pop("power_hp", None)
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136
|
||||
|
||||
|
||||
class TestVehicleDetail:
|
||||
"""GET /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles/:id returns 200 with detail."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == vehicle_id
|
||||
assert data["make"] == created_vehicle["make"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""GET /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestVehicleUpdate:
|
||||
"""PUT /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||
"""PUT /api/v1/vehicles/:id returns 200 with updated data."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle_id}",
|
||||
json={"price": "42000.00", "availability": "reserved"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert float(data["price"]) == 42000.00
|
||||
assert data["availability"] == "reserved"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""PUT /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{fake_id}",
|
||||
json={"price": "42000.00"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_no_fields_returns_400(self, admin_client, created_vehicle):
|
||||
"""PUT /api/v1/vehicles/:id with no fields returns 400."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle_id}",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestVehicleDelete:
|
||||
"""DELETE /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["deleted_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""DELETE /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.delete(f"/api/v1/vehicles/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_vehicle_not_in_list(self, admin_client, created_vehicle):
|
||||
"""After soft-delete, vehicle does not appear in list."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
response = await admin_client.get("/api/v1/vehicles/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["id"] != vehicle_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_vehicle_returns_404_on_detail(self, admin_client, created_vehicle):
|
||||
"""After soft-delete, GET /api/v1/vehicles/:id returns 404."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMobileDePush:
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_returns_202(self, admin_client, created_vehicle):
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "mobile-de-ad-123"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["message"] == "Push queued"
|
||||
assert data["vehicle_id"] == vehicle_id
|
||||
assert data["listing_id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_nonexistent_vehicle_returns_404(self, admin_client):
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{fake_id}/mobile-de/push")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMobileDeStatus:
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is False
|
||||
assert data["sync_status"] == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "mobile-de-ad-456"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
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")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is True
|
||||
assert data["ad_id"] == "mobile-de-ad-456"
|
||||
assert data["synced_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_nonexistent_vehicle_returns_404(self, admin_client):
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}/mobile-de/status")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Additional tests for vehicle_service and router to reach 80% coverage."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.services import vehicle_service
|
||||
|
||||
|
||||
def _make_vehicle_data(**overrides) -> dict:
|
||||
"""Return valid vehicle creation data with optional overrides."""
|
||||
defaults = {
|
||||
"make": "Volvo",
|
||||
"model": "FH16",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2021,
|
||||
"first_registration": date(2021, 6, 1),
|
||||
"power_kw": 500,
|
||||
"power_hp": 680,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Automatic",
|
||||
"color": "Red",
|
||||
"condition": "used",
|
||||
"location": "Hamburg",
|
||||
"availability": "available",
|
||||
"price": Decimal("85000.00"),
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 80000,
|
||||
"description": "Heavy duty truck",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_vehicle_data():
|
||||
"""Valid vehicle data for creation."""
|
||||
return {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": "2020-03-15",
|
||||
"power_kw": 300,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": 45000.00,
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||
"""Create a vehicle via API and return the response."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestVehicleServiceDirect:
|
||||
"""Direct service-layer tests for vehicle_service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_empty(self, db_session):
|
||||
"""list_vehicles returns empty list when no vehicles exist."""
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session)
|
||||
assert vehicles == []
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_pagination(self, db_session):
|
||||
"""list_vehicles respects page and page_size."""
|
||||
fins = ["WDB9066351L123450", "WDB9066351L123451", "WDB9066351L123452",
|
||||
"WDB9066351L123453", "WDB9066351L123454"]
|
||||
for fin in fins:
|
||||
data = _make_vehicle_data(fin=fin)
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, page=1, page_size=2)
|
||||
assert len(vehicles) == 2
|
||||
assert total == 5
|
||||
|
||||
vehicles_page2, _ = await vehicle_service.list_vehicles(db_session, page=2, page_size=2)
|
||||
assert len(vehicles_page2) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_sort_ascending(self, db_session):
|
||||
"""list_vehicles sorts ascending by make."""
|
||||
makes_fins = [("Zebra", "WDB9066351L000001"), ("Alpha", "WDB9066351L000002"), ("Mike", "WDB9066351L000003")]
|
||||
for make, fin in makes_fins:
|
||||
data = _make_vehicle_data(make=make, fin=fin)
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, _ = await vehicle_service.list_vehicles(db_session, sort="make")
|
||||
makes = [v.make for v in vehicles]
|
||||
assert makes == sorted(makes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, sort="invalid_field")
|
||||
assert total == 1
|
||||
assert len(vehicles) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_min_price_only(self, db_session):
|
||||
"""list_vehicles filters by min_price only."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||
db_session.add(Vehicle(**data1))
|
||||
db_session.add(Vehicle(**data2))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, min_price=50000)
|
||||
assert total == 1
|
||||
assert float(vehicles[0].price) >= 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_max_price_only(self, db_session):
|
||||
"""list_vehicles filters by max_price only."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||
db_session.add(Vehicle(**data1))
|
||||
db_session.add(Vehicle(**data2))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, max_price=40000)
|
||||
assert total == 1
|
||||
assert float(vehicles[0].price) <= 40000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search_by_fin(self, db_session):
|
||||
"""list_vehicles search matches FIN."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L123456")
|
||||
db_session.add(Vehicle(**data))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, search="123456")
|
||||
assert total == 1
|
||||
assert "123456" in vehicles[0].fin
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search_by_location(self, db_session):
|
||||
"""list_vehicles search matches location."""
|
||||
data = _make_vehicle_data(location="Munich")
|
||||
db_session.add(Vehicle(**data))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, search="Munich")
|
||||
assert total == 1
|
||||
assert vehicles[0].location == "Munich"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_fin(self, db_session):
|
||||
"""get_vehicle_by_fin returns vehicle by FIN."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L999999")
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
result = await vehicle_service.get_vehicle_by_fin(db_session, "WDB9066351L999999")
|
||||
assert result is not None
|
||||
assert result.fin == "WDB9066351L999999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_fin_not_found(self, db_session):
|
||||
"""get_vehicle_by_fin returns None for nonexistent FIN."""
|
||||
result = await vehicle_service.get_vehicle_by_fin(db_session, "NONEXISTENT1234567")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_success(self, db_session):
|
||||
"""create_vehicle creates and returns a vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
assert vehicle.id is not None
|
||||
assert vehicle.make == "Volvo"
|
||||
assert vehicle.fin == "WDB9066351L123456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_duplicate_fin_raises(self, db_session):
|
||||
"""create_vehicle raises ValueError for duplicate FIN."""
|
||||
data = _make_vehicle_data()
|
||||
await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_success(self, db_session):
|
||||
"""update_vehicle updates fields and returns updated vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
updated = await vehicle_service.update_vehicle(
|
||||
db_session, vehicle.id, {"make": "Scania", "price": Decimal("90000.00")}
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.make == "Scania"
|
||||
assert float(updated.price) == 90000.00
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_not_found(self, db_session):
|
||||
"""update_vehicle returns None for nonexistent ID."""
|
||||
result = await vehicle_service.update_vehicle(
|
||||
db_session, uuid.uuid4(), {"make": "Test"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_duplicate_fin_raises(self, db_session):
|
||||
"""update_vehicle raises ValueError when updating to existing FIN."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L111111")
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L222222")
|
||||
v1 = await vehicle_service.create_vehicle(db_session, data1)
|
||||
await vehicle_service.create_vehicle(db_session, data2)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await vehicle_service.update_vehicle(
|
||||
db_session, v1.id, {"fin": "WDB9066351L222222"}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_same_fin_no_error(self, db_session):
|
||||
"""update_vehicle allows setting same FIN (no change)."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L333333")
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
updated = await vehicle_service.update_vehicle(
|
||||
db_session, vehicle.id, {"fin": "WDB9066351L333333"}
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.fin == "WDB9066351L333333"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_vehicle_success(self, db_session):
|
||||
"""soft_delete_vehicle sets deleted_at."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
deleted = await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||
assert deleted is not None
|
||||
assert deleted.deleted_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_vehicle_not_found(self, db_session):
|
||||
"""soft_delete_vehicle returns None for nonexistent ID."""
|
||||
result = await vehicle_service.soft_delete_vehicle(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_id_not_found(self, db_session):
|
||||
"""get_vehicle_by_id returns None for nonexistent ID."""
|
||||
result = await vehicle_service.get_vehicle_by_id(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_id_excludes_deleted(self, db_session):
|
||||
"""get_vehicle_by_id returns None for soft-deleted vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||
|
||||
result = await vehicle_service.get_vehicle_by_id(db_session, vehicle.id)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRouterAdditionalPaths:
|
||||
"""Additional router tests for error paths and edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_with_all_filters(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles with all filters combined."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/vehicles/?type=lkw&availability=available&min_price=40000&max_price=50000&search=Mercedes&sort=-price"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_verkaeufer_allowed(self, verkaeufer_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles works for verkaeufer role (not admin-only)."""
|
||||
sample_vehicle_data["fin"] = "WDB9066351L654321"
|
||||
response = await verkaeufer_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
sample_vehicle_data["fin"] = "WDB9066351L111111"
|
||||
resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert resp1.status_code == 201
|
||||
vehicle1_id = resp1.json()["id"]
|
||||
|
||||
sample_vehicle_data["fin"] = "WDB9066351L222222"
|
||||
resp2 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert resp2.status_code == 201
|
||||
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle1_id}",
|
||||
json={"fin": "WDB9066351L222222"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_empty_result(self, admin_client):
|
||||
"""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")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_invalid_uuid_returns_422(self, admin_client):
|
||||
"""GET /api/v1/vehicles/invalid-uuid returns 422."""
|
||||
response = await admin_client.get("/api/v1/vehicles/not-a-uuid")
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
import httpx
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
import httpx
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
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")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is False
|
||||
assert data["sync_status"] == "fehler"
|
||||
assert data["error_log"] is not None
|
||||
@@ -0,0 +1,16 @@
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { I18nProvider, useI18n } from '@/lib/i18n';
|
||||
import { login as apiLogin, setTokens } from '@/lib/api';
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: { email?: string; password?: string } = {};
|
||||
if (!email) {
|
||||
newErrors.email = t('login.error.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t('login.error.emailInvalid');
|
||||
}
|
||||
if (!password) {
|
||||
newErrors.password = t('login.error.passwordRequired');
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const tokens = await apiLogin(email, password);
|
||||
setTokens(tokens);
|
||||
showToast(t('login.success'), 'success');
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
const message = (err as any)?.error?.message || t('login.error.invalidCredentials');
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" title={t('login.title')}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" data-testid="login-form">
|
||||
<Input
|
||||
label={t('login.email')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={errors.email}
|
||||
placeholder="user@example.com"
|
||||
data-testid="login-email"
|
||||
/>
|
||||
<Input
|
||||
label={t('login.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={errors.password}
|
||||
placeholder="********"
|
||||
data-testid="login-password"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
data-testid="login-submit"
|
||||
>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>
|
||||
<LoginForm />
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VehicleDetail } from '@/components/vehicles/VehicleDetail';
|
||||
|
||||
export default async function FahrzeugDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<VehicleDetail vehicleId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { VehicleForm } from '@/components/vehicles/VehicleForm';
|
||||
|
||||
export default function NeuesFahrzeugPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold text-text mb-6">Neues Fahrzeug</h1>
|
||||
<VehicleForm mode="create" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VehicleList } from '@/components/vehicles/VehicleList';
|
||||
|
||||
export default function FahrzeugePage() {
|
||||
return <VehicleList />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ContactDetail } from '@/components/contacts/ContactDetail';
|
||||
|
||||
export default async function KontaktDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string; id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
return <ContactDetail contactId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ContactForm } from '@/components/contacts/ContactForm';
|
||||
|
||||
export default function NeuerKontaktPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-text">Neuer Kontakt</h1>
|
||||
<ContactForm mode="create" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContactList } from '@/components/contacts/ContactList';
|
||||
|
||||
export default function KontaktePage() {
|
||||
return <ContactList />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-secondary: #64748b;
|
||||
--color-background: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-text: #1e293b;
|
||||
--color-text-muted: #64748b;
|
||||
--color-error: #dc2626;
|
||||
--color-success: #16a34a;
|
||||
--color-border: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ERP Nutzfahrzeuge',
|
||||
description: 'ERP system for commercial vehicle management',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<div id="app-root">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
getContact,
|
||||
deleteContact,
|
||||
addContactPerson,
|
||||
removeContactPerson,
|
||||
type ContactResponse,
|
||||
type ContactPersonResponse,
|
||||
} from '@/lib/contacts';
|
||||
|
||||
interface ContactDetailProps {
|
||||
contactId: string;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
export function ContactDetail({ contactId }: ContactDetailProps) {
|
||||
const router = useRouter();
|
||||
const [contact, setContact] = useState<ContactResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPersonModal, setShowPersonModal] = useState(false);
|
||||
const [personForm, setPersonForm] = useState({ name: '', function: '', phone: '', email: '' });
|
||||
const [personError, setPersonError] = useState<string | null>(null);
|
||||
const [addingPerson, setAddingPerson] = useState(false);
|
||||
|
||||
const fetchContact = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContact(contactId);
|
||||
setContact(data);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contactId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContact();
|
||||
}, [fetchContact]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!contact) return;
|
||||
if (!confirm('Diesen Kontakt löschen?')) return;
|
||||
try {
|
||||
await deleteContact(contact.id);
|
||||
router.push('/de/kontakte');
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to delete contact');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPerson = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!contact || !personForm.name.trim()) return;
|
||||
setAddingPerson(true);
|
||||
setPersonError(null);
|
||||
try {
|
||||
await addContactPerson(contact.id, {
|
||||
name: personForm.name,
|
||||
function: personForm.function || undefined,
|
||||
phone: personForm.phone || undefined,
|
||||
email: personForm.email || undefined,
|
||||
});
|
||||
setShowPersonModal(false);
|
||||
setPersonForm({ name: '', function: '', phone: '', email: '' });
|
||||
await fetchContact();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setPersonError(apiErr?.error?.message || 'Failed to add person');
|
||||
} finally {
|
||||
setAddingPerson(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePerson = async (personId: string) => {
|
||||
if (!contact) return;
|
||||
if (!confirm('Diese Kontaktperson entfernen?')) return;
|
||||
try {
|
||||
await removeContactPerson(contact.id, personId);
|
||||
await fetchContact();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to remove person');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div data-testid="contact-detail-loading" className="text-center py-8 text-text-muted">
|
||||
Loading contact...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="contact-detail-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div data-testid="contact-detail-not-found" className="text-center py-8 text-text-muted">
|
||||
Contact not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields: { label: string; value: string | number | undefined | null }[] = [
|
||||
{ label: 'Firmenname', value: contact.company_name },
|
||||
{ label: 'Rechtsform', value: contact.legal_form },
|
||||
{ label: 'Straße', value: contact.address_street },
|
||||
{ label: 'PLZ', value: contact.address_zip },
|
||||
{ label: 'Stadt', value: contact.address_city },
|
||||
{ label: 'Land', value: contact.address_country },
|
||||
{ label: 'USt-IdNr.', value: contact.vat_id },
|
||||
{ label: 'USt-Status', value: contact.vat_id_status },
|
||||
{ label: 'Telefon', value: contact.phone },
|
||||
{ label: 'E-Mail', value: contact.email },
|
||||
{ label: 'Website', value: contact.website },
|
||||
{ label: 'Rolle', value: contact.role },
|
||||
{ label: 'Privat', value: contact.is_private ? 'Ja' : 'Nein' },
|
||||
{ label: 'Erstellt am', value: formatDate(contact.created_at) },
|
||||
{ label: 'Aktualisiert am', value: formatDate(contact.updated_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-testid="contact-detail" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 data-testid="contact-detail-title" className="text-2xl font-bold text-text">
|
||||
{contact.company_name}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => router.push(`/de/kontakte/${contact.id}/bearbeiten`)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} data-testid="delete-button">
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="contact-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
|
||||
{fields.map(field => (
|
||||
<div key={field.label} className="space-y-1">
|
||||
<dt className="text-sm font-medium text-text-muted">{field.label}</dt>
|
||||
<dd data-testid={`field-${field.label.toLowerCase().replace(/\s+/g, '_')}`} className="text-text">
|
||||
{field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card title="Kontaktpersonen">
|
||||
<div data-testid="contact-persons-section" className="space-y-3">
|
||||
{contact.contact_persons && contact.contact_persons.length > 0 ? (
|
||||
contact.contact_persons.map((person: ContactPersonResponse) => (
|
||||
<div key={person.id} data-testid={`contact-person-${person.id}`} className="flex items-center justify-between p-3 bg-background/50 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-text">{person.name}</p>
|
||||
{person.function && <p className="text-sm text-text-muted">{person.function}</p>}
|
||||
{person.phone && <p className="text-sm text-text-muted">Tel: {person.phone}</p>}
|
||||
{person.email && <p className="text-sm text-text-muted">E-Mail: {person.email}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleRemovePerson(person.id)}
|
||||
data-testid={`remove-person-${person.id}`}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p data-testid="no-contact-persons" className="text-text-muted text-center py-4">
|
||||
Keine Kontaktpersonen vorhanden.
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={() => setShowPersonModal(true)} data-testid="add-person-btn">
|
||||
+ Kontaktperson hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal open={showPersonModal} onClose={() => setShowPersonModal(false)} title="Kontaktperson hinzufügen">
|
||||
<form data-testid="person-form" onSubmit={handleAddPerson} className="space-y-4">
|
||||
{personError && (
|
||||
<div data-testid="person-form-error" className="p-3 bg-error/10 text-error rounded-lg">
|
||||
{personError}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Name *"
|
||||
data-testid="person-input-name"
|
||||
value={personForm.name}
|
||||
onChange={e => setPersonForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Funktion"
|
||||
data-testid="person-input-function"
|
||||
value={personForm.function}
|
||||
onChange={e => setPersonForm(prev => ({ ...prev, function: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Telefon"
|
||||
data-testid="person-input-phone"
|
||||
value={personForm.phone}
|
||||
onChange={e => setPersonForm(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail"
|
||||
data-testid="person-input-email"
|
||||
type="email"
|
||||
value={personForm.email}
|
||||
onChange={e => setPersonForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={addingPerson} data-testid="person-submit">
|
||||
Hinzufügen
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setShowPersonModal(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
createContact,
|
||||
updateContact,
|
||||
validateVatIdFormat,
|
||||
type ContactResponse,
|
||||
type ContactCreateData,
|
||||
} from '@/lib/contacts';
|
||||
|
||||
const ROLE_OPTIONS = ['kaeufer', 'verkaeufer', 'beide'];
|
||||
const LEGAL_FORMS = ['GmbH', 'AG', 'KG', 'OHG', 'GbR', 'e.K.', 'UG', 'SE', 'Einzelunternehmen'];
|
||||
const COUNTRY_OPTIONS = [
|
||||
'DE', 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR',
|
||||
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
|
||||
'SE', 'SI', 'SK',
|
||||
];
|
||||
|
||||
interface ContactFormProps {
|
||||
contact?: ContactResponse;
|
||||
mode?: 'create' | 'edit';
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, mode = 'create' }: ContactFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<ContactCreateData>({
|
||||
company_name: contact?.company_name || '',
|
||||
legal_form: contact?.legal_form,
|
||||
address_street: contact?.address_street,
|
||||
address_zip: contact?.address_zip,
|
||||
address_city: contact?.address_city,
|
||||
address_country: contact?.address_country || 'DE',
|
||||
vat_id: contact?.vat_id,
|
||||
phone: contact?.phone,
|
||||
email: contact?.email,
|
||||
website: contact?.website,
|
||||
role: contact?.role || 'kaeufer',
|
||||
is_private: contact?.is_private || false,
|
||||
});
|
||||
|
||||
const isEu = formData.address_country !== 'DE';
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.company_name || formData.company_name.trim().length === 0) {
|
||||
newErrors.company_name = 'Company name is required';
|
||||
}
|
||||
if (!formData.role) {
|
||||
newErrors.role = 'Role is required';
|
||||
}
|
||||
if (!formData.address_country || formData.address_country.length !== 2) {
|
||||
newErrors.address_country = 'Country code is required (2 letters)';
|
||||
}
|
||||
|
||||
// VAT ID validation
|
||||
if (formData.vat_id) {
|
||||
const vatError = validateVatIdFormat(formData.vat_id, formData.address_country);
|
||||
if (vatError) {
|
||||
newErrors.vat_id = vatError;
|
||||
}
|
||||
}
|
||||
|
||||
// EU contacts should have a VAT ID (warning, not error)
|
||||
if (isEu && !formData.vat_id) {
|
||||
// Soft warning - don't block submission
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof ContactCreateData, value: string | boolean | undefined) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => { const next = { ...prev }; delete next[field]; return next; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const submitData = { ...formData };
|
||||
// Remove undefined empty strings
|
||||
Object.keys(submitData).forEach(key => {
|
||||
if (submitData[key as keyof ContactCreateData] === '') {
|
||||
(submitData as Record<string, unknown>)[key] = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'edit' && contact) {
|
||||
await updateContact(contact.id, submitData);
|
||||
router.push(`/de/kontakte/${contact.id}`);
|
||||
} else {
|
||||
const created = await createContact(submitData);
|
||||
router.push(`/de/kontakte/${created.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setSubmitError(apiErr?.error?.message || 'Failed to save contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border rounded-lg bg-surface text-text border-border focus:outline-none focus:ring-2 focus:ring-primary';
|
||||
|
||||
return (
|
||||
<form data-testid="contact-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{submitError && (
|
||||
<div data-testid="form-submit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EU / Inland Toggle */}
|
||||
<div data-testid="eu-inland-toggle" className="flex gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="toggle-inland"
|
||||
name="region"
|
||||
value="DE"
|
||||
checked={formData.address_country === 'DE'}
|
||||
onChange={() => handleChange('address_country', 'DE')}
|
||||
/>
|
||||
<span className="text-text font-medium">Inland (DE)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
data-testid="toggle-eu"
|
||||
name="region"
|
||||
value="EU"
|
||||
checked={formData.address_country !== 'DE'}
|
||||
onChange={() => handleChange('address_country', 'AT')}
|
||||
/>
|
||||
<span className="text-text font-medium">EU (Ausland)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Firmenname *"
|
||||
data-testid="input-company_name"
|
||||
value={formData.company_name}
|
||||
onChange={e => handleChange('company_name', e.target.value)}
|
||||
error={errors.company_name}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Rechtsform</label>
|
||||
<select
|
||||
data-testid="input-legal_form"
|
||||
className={inputClass}
|
||||
value={formData.legal_form || ''}
|
||||
onChange={e => handleChange('legal_form', e.target.value || undefined)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{LEGAL_FORMS.map(lf => <option key={lf} value={lf}>{lf}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Rolle *</label>
|
||||
<select
|
||||
data-testid="input-role"
|
||||
className={inputClass}
|
||||
value={formData.role}
|
||||
onChange={e => handleChange('role', e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
{errors.role && <p className="mt-1 text-sm text-error">{errors.role}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Land *</label>
|
||||
<select
|
||||
data-testid="input-address_country"
|
||||
className={inputClass}
|
||||
value={formData.address_country}
|
||||
onChange={e => handleChange('address_country', e.target.value)}
|
||||
>
|
||||
{COUNTRY_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{errors.address_country && <p className="mt-1 text-sm text-error">{errors.address_country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Straße"
|
||||
data-testid="input-address_street"
|
||||
value={formData.address_street || ''}
|
||||
onChange={e => handleChange('address_street', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="PLZ"
|
||||
data-testid="input-address_zip"
|
||||
value={formData.address_zip || ''}
|
||||
onChange={e => handleChange('address_zip', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Stadt"
|
||||
data-testid="input-address_city"
|
||||
value={formData.address_city || ''}
|
||||
onChange={e => handleChange('address_city', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
label={isEu ? 'USt-IdNr. (empfohlen)' : 'USt-IdNr.'}
|
||||
data-testid="input-vat_id"
|
||||
value={formData.vat_id || ''}
|
||||
onChange={e => handleChange('vat_id', e.target.value || undefined)}
|
||||
error={errors.vat_id}
|
||||
placeholder={isEu ? 'z.B. ATU12345678' : 'z.B. DE123456789'}
|
||||
/>
|
||||
{isEu && !formData.vat_id && (
|
||||
<p data-testid="vat-id-hint" className="mt-1 text-sm text-text-muted">
|
||||
Für EU-Kontakte wird eine USt-IdNr. empfohlen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Telefon"
|
||||
data-testid="input-phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={e => handleChange('phone', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="E-Mail"
|
||||
data-testid="input-email"
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={e => handleChange('email', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Website"
|
||||
data-testid="input-website"
|
||||
value={formData.website || ''}
|
||||
onChange={e => handleChange('website', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="input-is_private"
|
||||
checked={formData.is_private || false}
|
||||
onChange={e => handleChange('is_private', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-text">Privatkontakt</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading} data-testid="submit-button">
|
||||
{mode === 'edit' ? 'Kontakt aktualisieren' : 'Kontakt erstellen'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
listContacts,
|
||||
type ContactResponse,
|
||||
type ContactListParams,
|
||||
} from '@/lib/contacts';
|
||||
import type { PaginatedResponse } from '@/lib/api';
|
||||
|
||||
const ROLE_OPTIONS = ['kaeufer', 'verkaeufer', 'beide'];
|
||||
const SORT_OPTIONS = [
|
||||
{ value: '-created_at', label: 'Newest First' },
|
||||
{ value: 'created_at', label: 'Oldest First' },
|
||||
{ value: 'company_name', label: 'Company A-Z' },
|
||||
{ value: '-company_name', label: 'Company Z-A' },
|
||||
{ value: 'address_city', label: 'City A-Z' },
|
||||
];
|
||||
|
||||
export function ContactList() {
|
||||
const router = useRouter();
|
||||
const [contacts, setContacts] = useState<ContactResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<ContactListParams>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const fetchContacts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: PaginatedResponse<ContactResponse> = await listContacts(filters);
|
||||
setContacts(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load contacts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [fetchContacts]);
|
||||
|
||||
const handleFilterChange = (key: keyof ContactListParams, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
[key]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEuToggle = (value: string) => {
|
||||
if (value === '') {
|
||||
setFilters(prev => ({ ...prev, page: 1, is_eu: undefined }));
|
||||
} else {
|
||||
setFilters(prev => ({ ...prev, page: 1, is_eu: value === 'eu' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Company',
|
||||
render: (row: ContactResponse) => (
|
||||
<button
|
||||
data-testid={`contact-row-${row.id}`}
|
||||
onClick={() => router.push(`/de/kontakte/${row.id}`)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.company_name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'address_city', label: 'City' },
|
||||
{ key: 'address_country', label: 'Country' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
{
|
||||
key: 'vat_id',
|
||||
label: 'VAT ID',
|
||||
render: (row: ContactResponse) => row.vat_id || '—',
|
||||
},
|
||||
{
|
||||
key: 'vat_id_status',
|
||||
label: 'VAT Status',
|
||||
render: (row: ContactResponse) => (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
row.vat_id_status === 'geprueft' || row.vat_id_status === 'manuell_bestaetigt'
|
||||
? 'bg-success/20 text-success'
|
||||
: row.vat_id_status === 'ungueltig'
|
||||
? 'bg-error/20 text-error'
|
||||
: 'bg-secondary/20 text-secondary'
|
||||
}`}
|
||||
>
|
||||
{row.vat_id_status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'contact_persons',
|
||||
label: 'Persons',
|
||||
render: (row: ContactResponse) => String(row.contact_persons?.length || 0),
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div data-testid="contact-list" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text">Kontakte</h1>
|
||||
<Button onClick={() => router.push('/de/kontakte/neu')} data-testid="new-contact-btn">
|
||||
+ Neuer Kontakt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-testid="contact-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-text mb-1">Search</label>
|
||||
<Input
|
||||
data-testid="filter-search"
|
||||
type="text"
|
||||
placeholder="Company, city, email..."
|
||||
value={filters.search || ''}
|
||||
onChange={e => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Role</label>
|
||||
<select
|
||||
data-testid="filter-role"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.role || ''}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{ROLE_OPTIONS.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Region</label>
|
||||
<select
|
||||
data-testid="filter-eu"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.is_eu === undefined ? '' : filters.is_eu ? 'eu' : 'inland'}
|
||||
onChange={e => handleEuToggle(e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="inland">Inland (DE)</option>
|
||||
<option value="eu">EU (non-DE)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Sort</label>
|
||||
<select
|
||||
data-testid="filter-sort"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.sort || ''}
|
||||
onChange={e => handleFilterChange('sort', e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{SORT_OPTIONS.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="contact-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="contact-loading" className="text-center py-8 text-text-muted">
|
||||
Loading contacts...
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={contacts} rowKey={row => row.id} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div data-testid="contact-pagination" className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary: 'bg-primary text-white hover:bg-primary-hover focus:ring-primary',
|
||||
secondary: 'bg-secondary text-white hover:bg-secondary/80 focus:ring-secondary',
|
||||
danger: 'bg-error text-white hover:bg-error/80 focus:ring-error',
|
||||
ghost: 'bg-transparent text-primary hover:bg-primary/10 focus:ring-primary',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', loading = false, children, className = '', disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, children, className = '', ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-surface rounded-xl shadow-sm border border-border p-6 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{title && <h2 className="text-xl font-semibold text-text mb-4">{title}</h2>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, className = '', id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-text mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`w-full px-3 py-2 border rounded-lg bg-surface text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${error ? 'border-error' : 'border-border'} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} data-testid="modal-overlay" />
|
||||
<div className="relative bg-surface rounded-xl shadow-lg max-w-md w-full mx-4 p-6" data-testid="modal-content">
|
||||
{title && <h2 className="text-lg font-semibold mb-4">{title}</h2>}
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-text-muted hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode }[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string;
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({ columns, data, rowKey }: TableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="text-left py-3 px-4 font-medium text-text-muted text-sm">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={rowKey(row)} className="border-b border-border hover:bg-background/50">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="py-3 px-4 text-text">
|
||||
{col.render ? col.render(row) : String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="py-8 text-center text-text-muted">
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const toastColors: Record<ToastType, string> = {
|
||||
success: 'bg-success text-white',
|
||||
error: 'bg-error text-white',
|
||||
info: 'bg-primary text-white',
|
||||
warning: 'bg-secondary text-white',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { id, type, message }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" data-testid="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${toastColors[toast.type]} px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px]`}
|
||||
data-testid={`toast-${toast.id}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button onClick={() => removeToast(toast.id)} className="text-white/80 hover:text-white" aria-label="Dismiss">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
getMobileDeStatus,
|
||||
pushToMobileDe,
|
||||
type MobileDeStatusResponse,
|
||||
} from '@/lib/vehicles';
|
||||
|
||||
interface MobileDeStatusProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
synced: 'bg-green-100 text-green-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
fehler: 'bg-red-100 text-red-800',
|
||||
deleted: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
synced: 'Synced',
|
||||
pending: 'Pending',
|
||||
fehler: 'Error',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
export function MobileDeStatus({ vehicleId }: MobileDeStatusProps) {
|
||||
const [status, setStatus] = useState<MobileDeStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getMobileDeStatus(vehicleId);
|
||||
setStatus(data);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load mobile.de status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const handlePush = async () => {
|
||||
setPushing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pushToMobileDe(vehicleId);
|
||||
await fetchStatus();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to push to mobile.de');
|
||||
} finally {
|
||||
setPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncStatus = status?.sync_status || 'pending';
|
||||
const statusStyle = STATUS_STYLES[syncStatus] || 'bg-gray-100 text-gray-800';
|
||||
const statusLabel = STATUS_LABELS[syncStatus] || syncStatus;
|
||||
|
||||
return (
|
||||
<div data-testid="mobile-de-status" className="p-6 bg-surface rounded-lg border border-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text">mobile.de Status</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={pushing}
|
||||
onClick={handlePush}
|
||||
data-testid="mobile-de-push-button"
|
||||
>
|
||||
Push to mobile.de
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="mobile-de-loading" className="text-text-muted">
|
||||
Loading status...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div data-testid="mobile-de-error" className="text-error">
|
||||
{error}
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-text-muted">Sync Status:</span>
|
||||
<span
|
||||
data-testid="mobile-de-sync-status"
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div data-testid="mobile-de-synced" className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-muted">Synced</dt>
|
||||
<dd className="text-text">{status.synced ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-muted">Ad ID</dt>
|
||||
<dd className="text-text">{status.ad_id || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-text-muted">Synced At</dt>
|
||||
<dd className="text-text">
|
||||
{status.synced_at ? new Date(status.synced_at).toLocaleString('de-DE') : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.error_log && (
|
||||
<div data-testid="mobile-de-error-log" className="p-3 bg-error/10 text-error rounded-lg text-sm">
|
||||
<strong>Error:</strong> {status.error_log}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
getVehicle,
|
||||
deleteVehicle,
|
||||
type VehicleResponse,
|
||||
} from '@/lib/vehicles';
|
||||
import { MobileDeStatus } from './MobileDeStatus';
|
||||
|
||||
interface VehicleDetailProps {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price);
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
||||
const router = useRouter();
|
||||
const [vehicle, setVehicle] = useState<VehicleResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchVehicle = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getVehicle(vehicleId);
|
||||
setVehicle(data);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVehicle();
|
||||
}, [fetchVehicle]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!vehicle) return;
|
||||
if (!confirm('Delete this vehicle?')) return;
|
||||
try {
|
||||
await deleteVehicle(vehicle.id);
|
||||
router.push('/de/fahrzeuge');
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to delete vehicle');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-loading" className="text-center py-8 text-text-muted">
|
||||
Loading vehicle...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<div data-testid="vehicle-detail-not-found" className="text-center py-8 text-text-muted">
|
||||
Vehicle not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields: { label: string; value: string | number | undefined | null }[] = [
|
||||
{ label: 'Make', value: vehicle.make },
|
||||
{ label: 'Model', value: vehicle.model },
|
||||
{ label: 'FIN', value: vehicle.fin },
|
||||
{ label: 'Year', value: vehicle.year },
|
||||
{ label: 'First Registration', value: formatDate(vehicle.first_registration) },
|
||||
{ label: 'Power (kW)', value: vehicle.power_kw },
|
||||
{ label: 'Power (HP)', value: vehicle.power_hp },
|
||||
{ label: 'Fuel Type', value: vehicle.fuel_type },
|
||||
{ label: 'Transmission', value: vehicle.transmission },
|
||||
{ label: 'Color', value: vehicle.color },
|
||||
{ label: 'Condition', value: vehicle.condition },
|
||||
{ label: 'Location', value: vehicle.location },
|
||||
{ label: 'Availability', value: vehicle.availability },
|
||||
{ label: 'Price', value: formatPrice(vehicle.price) },
|
||||
{ label: 'Vehicle Type', value: vehicle.vehicle_type },
|
||||
{ label: 'LKW Type', value: vehicle.lkw_type },
|
||||
{ label: 'Machine Type', value: vehicle.machine_type },
|
||||
{ label: 'Body Type', value: vehicle.body_type },
|
||||
{ label: 'Operating Hours', value: vehicle.operating_hours },
|
||||
{ label: 'Operating Hours Unit', value: vehicle.operating_hours_unit },
|
||||
{ label: 'Mileage (km)', value: vehicle.mileage_km },
|
||||
{ label: 'Description', value: vehicle.description },
|
||||
{ label: 'Created At', value: formatDate(vehicle.created_at) },
|
||||
{ label: 'Updated At', value: formatDate(vehicle.updated_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-testid="vehicle-detail" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 data-testid="vehicle-detail-title" className="text-2xl font-bold text-text">
|
||||
{vehicle.make} {vehicle.model}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => router.push(`/de/fahrzeuge/${vehicle.id}/bearbeiten`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} data-testid="delete-button">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid="vehicle-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
|
||||
{fields.map(field => (
|
||||
<div key={field.label} className="space-y-1">
|
||||
<dt className="text-sm font-medium text-text-muted">{field.label}</dt>
|
||||
<dd data-testid={`field-${field.label.toLowerCase().replace(/\s+/g, '_')}`} className="text-text">
|
||||
{field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MobileDeStatus vehicleId={vehicle.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
type VehicleResponse,
|
||||
type VehicleCreateData,
|
||||
} from '@/lib/vehicles';
|
||||
|
||||
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||
const CONDITIONS = ['new', 'used'];
|
||||
const AVAILABILITY = ['available', 'reserved', 'sold'];
|
||||
const FUEL_TYPES = ['Diesel', 'Petrol', 'Electric', 'Hybrid', 'Gas'];
|
||||
const TRANSMISSIONS = ['Manual', 'Automatic', 'Semi-Automatic'];
|
||||
const HOURS_UNITS = ['h', 'min'];
|
||||
|
||||
interface VehicleFormProps {
|
||||
vehicle?: VehicleResponse;
|
||||
mode?: 'create' | 'edit';
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function VehicleForm({ vehicle, mode = 'create' }: VehicleFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<VehicleCreateData>({
|
||||
make: vehicle?.make || '',
|
||||
model: vehicle?.model || '',
|
||||
fin: vehicle?.fin || '',
|
||||
year: vehicle?.year,
|
||||
first_registration: vehicle?.first_registration,
|
||||
power_kw: vehicle?.power_kw,
|
||||
fuel_type: vehicle?.fuel_type,
|
||||
transmission: vehicle?.transmission,
|
||||
color: vehicle?.color,
|
||||
condition: vehicle?.condition || 'used',
|
||||
location: vehicle?.location,
|
||||
availability: vehicle?.availability || 'available',
|
||||
price: vehicle?.price || 0,
|
||||
vehicle_type: vehicle?.vehicle_type || 'lkw',
|
||||
lkw_type: vehicle?.lkw_type,
|
||||
machine_type: vehicle?.machine_type,
|
||||
body_type: vehicle?.body_type,
|
||||
operating_hours: vehicle?.operating_hours,
|
||||
operating_hours_unit: vehicle?.operating_hours_unit,
|
||||
mileage_km: vehicle?.mileage_km,
|
||||
description: vehicle?.description,
|
||||
});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.make || formData.make.trim().length === 0) {
|
||||
newErrors.make = 'Make is required';
|
||||
}
|
||||
if (!formData.model || formData.model.trim().length === 0) {
|
||||
newErrors.model = 'Model is required';
|
||||
}
|
||||
if (!formData.fin) {
|
||||
newErrors.fin = 'FIN is required';
|
||||
} else if (formData.fin.length !== 17) {
|
||||
newErrors.fin = 'FIN must be exactly 17 characters';
|
||||
}
|
||||
if (!formData.price || formData.price <= 0) {
|
||||
newErrors.price = 'Price must be greater than 0';
|
||||
}
|
||||
if (!formData.vehicle_type) {
|
||||
newErrors.vehicle_type = 'Vehicle type is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof VehicleCreateData, value: string | number | undefined) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => { const next = { ...prev }; delete next[field]; return next; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
if (mode === 'edit' && vehicle) {
|
||||
await updateVehicle(vehicle.id, formData);
|
||||
router.push(`/de/fahrzeuge/${vehicle.id}`);
|
||||
} else {
|
||||
const created = await createVehicle(formData);
|
||||
router.push(`/de/fahrzeuge/${created.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setSubmitError(apiErr?.error?.message || 'Failed to save vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border rounded-lg bg-surface text-text border-border focus:outline-none focus:ring-2 focus:ring-primary';
|
||||
|
||||
return (
|
||||
<form data-testid="vehicle-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{submitError && (
|
||||
<div data-testid="form-submit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Make *"
|
||||
data-testid="input-make"
|
||||
value={formData.make}
|
||||
onChange={e => handleChange('make', e.target.value)}
|
||||
error={errors.make}
|
||||
/>
|
||||
<Input
|
||||
label="Model *"
|
||||
data-testid="input-model"
|
||||
value={formData.model}
|
||||
onChange={e => handleChange('model', e.target.value)}
|
||||
error={errors.model}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="FIN * (17 characters)"
|
||||
data-testid="input-fin"
|
||||
value={formData.fin}
|
||||
maxLength={17}
|
||||
onChange={e => handleChange('fin', e.target.value.toUpperCase())}
|
||||
error={errors.fin}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Vehicle Type *</label>
|
||||
<select
|
||||
data-testid="input-vehicle_type"
|
||||
className={inputClass}
|
||||
value={formData.vehicle_type}
|
||||
onChange={e => handleChange('vehicle_type', e.target.value)}
|
||||
>
|
||||
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{errors.vehicle_type && <p className="mt-1 text-sm text-error">{errors.vehicle_type}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Price (EUR) *"
|
||||
data-testid="input-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price || ''}
|
||||
onChange={e => handleChange('price', parseFloat(e.target.value) || 0)}
|
||||
error={errors.price}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Availability</label>
|
||||
<select
|
||||
data-testid="input-availability"
|
||||
className={inputClass}
|
||||
value={formData.availability}
|
||||
onChange={e => handleChange('availability', e.target.value)}
|
||||
>
|
||||
{AVAILABILITY.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Year"
|
||||
data-testid="input-year"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={formData.year || ''}
|
||||
onChange={e => handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="First Registration"
|
||||
data-testid="input-first_registration"
|
||||
type="date"
|
||||
value={formData.first_registration || ''}
|
||||
onChange={e => handleChange('first_registration', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Power (kW)"
|
||||
data-testid="input-power_kw"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.power_kw || ''}
|
||||
onChange={e => handleChange('power_kw', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Fuel Type</label>
|
||||
<select
|
||||
data-testid="input-fuel_type"
|
||||
className={inputClass}
|
||||
value={formData.fuel_type || ''}
|
||||
onChange={e => handleChange('fuel_type', e.target.value || undefined)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{FUEL_TYPES.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Transmission</label>
|
||||
<select
|
||||
data-testid="input-transmission"
|
||||
className={inputClass}
|
||||
value={formData.transmission || ''}
|
||||
onChange={e => handleChange('transmission', e.target.value || undefined)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{TRANSMISSIONS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Condition</label>
|
||||
<select
|
||||
data-testid="input-condition"
|
||||
className={inputClass}
|
||||
value={formData.condition}
|
||||
onChange={e => handleChange('condition', e.target.value)}
|
||||
>
|
||||
{CONDITIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Color"
|
||||
data-testid="input-color"
|
||||
value={formData.color || ''}
|
||||
onChange={e => handleChange('color', e.target.value || undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Location"
|
||||
data-testid="input-location"
|
||||
value={formData.location || ''}
|
||||
onChange={e => handleChange('location', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.vehicle_type === 'lkw' && (
|
||||
<Input
|
||||
label="LKW Type"
|
||||
data-testid="input-lkw_type"
|
||||
value={formData.lkw_type || ''}
|
||||
onChange={e => handleChange('lkw_type', e.target.value || undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formData.vehicle_type === 'baumaschine' && (
|
||||
<Input
|
||||
label="Machine Type"
|
||||
data-testid="input-machine_type"
|
||||
value={formData.machine_type || ''}
|
||||
onChange={e => handleChange('machine_type', e.target.value || undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Mileage (km)"
|
||||
data-testid="input-mileage_km"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.mileage_km || ''}
|
||||
onChange={e => handleChange('mileage_km', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Operating Hours</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
data-testid="input-operating_hours"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={formData.operating_hours || ''}
|
||||
onChange={e => handleChange('operating_hours', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
/>
|
||||
<select
|
||||
data-testid="input-operating_hours_unit"
|
||||
className={inputClass}
|
||||
style={{ maxWidth: '80px' }}
|
||||
value={formData.operating_hours_unit || 'h'}
|
||||
onChange={e => handleChange('operating_hours_unit', e.target.value || undefined)}
|
||||
>
|
||||
{HOURS_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text mb-1">Description</label>
|
||||
<textarea
|
||||
data-testid="input-description"
|
||||
className={inputClass}
|
||||
rows={4}
|
||||
value={formData.description || ''}
|
||||
onChange={e => handleChange('description', e.target.value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" loading={loading} data-testid="submit-button">
|
||||
{mode === 'edit' ? 'Update Vehicle' : 'Create Vehicle'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
listVehicles,
|
||||
type VehicleResponse,
|
||||
type VehicleListParams,
|
||||
} from '@/lib/vehicles';
|
||||
import type { PaginatedResponse } from '@/lib/api';
|
||||
|
||||
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
||||
const SORT_OPTIONS = [
|
||||
{ value: '-created_at', label: 'Newest First' },
|
||||
{ value: 'created_at', label: 'Oldest First' },
|
||||
{ value: 'make', label: 'Make A-Z' },
|
||||
{ value: '-make', label: 'Make Z-A' },
|
||||
{ value: '-price', label: 'Price High-Low' },
|
||||
{ value: 'price', label: 'Price Low-High' },
|
||||
];
|
||||
|
||||
export function VehicleList() {
|
||||
const router = useRouter();
|
||||
const [vehicles, setVehicles] = useState<VehicleResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<VehicleListParams>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
const fetchVehicles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: PaginatedResponse<VehicleResponse> = await listVehicles(filters);
|
||||
setVehicles(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Failed to load vehicles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVehicles();
|
||||
}, [fetchVehicles]);
|
||||
|
||||
const handleFilterChange = (key: keyof VehicleListParams, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
[key]: value || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'make',
|
||||
label: 'Make',
|
||||
render: (row: VehicleResponse) => (
|
||||
<button
|
||||
data-testid={`vehicle-row-${row.id}`}
|
||||
onClick={() => router.push(`/de/fahrzeuge/${row.id}`)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.make}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'model', label: 'Model' },
|
||||
{ key: 'fin', label: 'FIN' },
|
||||
{ key: 'vehicle_type', label: 'Type' },
|
||||
{ key: 'availability', label: 'Availability' },
|
||||
{
|
||||
key: 'price',
|
||||
label: 'Price',
|
||||
render: (row: VehicleResponse) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(row.price),
|
||||
},
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div data-testid="vehicle-list" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
||||
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
||||
+ Neues Fahrzeug
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-text mb-1">Search</label>
|
||||
<Input
|
||||
data-testid="filter-search"
|
||||
type="text"
|
||||
placeholder="Make, model, FIN..."
|
||||
value={filters.search || ''}
|
||||
onChange={e => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Type</label>
|
||||
<select
|
||||
data-testid="filter-type"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.type || ''}
|
||||
onChange={e => handleFilterChange('type', e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{VEHICLE_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Availability</label>
|
||||
<select
|
||||
data-testid="filter-availability"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.availability || ''}
|
||||
onChange={e => handleFilterChange('availability', e.target.value)}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{AVAILABILITY_OPTIONS.map(a => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm font-medium text-text mb-1">Sort</label>
|
||||
<select
|
||||
data-testid="filter-sort"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||
value={filters.sort || ''}
|
||||
onChange={e => handleFilterChange('sort', e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{SORT_OPTIONS.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="vehicle-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="vehicle-loading" className="text-center py-8 text-text-muted">
|
||||
Loading vehicles...
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={vehicles} rowKey={row => row.id} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div data-testid="vehicle-pagination" className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
language: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
export function setTokens(tokens: TokenResponse): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getAuthToken() !== null;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401 && token) {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (refreshToken) {
|
||||
const refreshResult = await apiFetch<TokenResponse>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
setTokens(refreshResult);
|
||||
headers['Authorization'] = `Bearer ${refreshResult.access_token}`;
|
||||
const retryResponse = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (!retryResponse.ok) {
|
||||
const err = await retryResponse.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Request failed' } }));
|
||||
throw err as ApiError;
|
||||
}
|
||||
return retryResponse.json();
|
||||
}
|
||||
clearTokens();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Request failed' } }));
|
||||
throw err as ApiError;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<TokenResponse> {
|
||||
return apiFetch<TokenResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<UserResponse> {
|
||||
return apiFetch<UserResponse>('/auth/me');
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20): Promise<PaginatedResponse<UserResponse>> {
|
||||
return apiFetch<PaginatedResponse<UserResponse>>(`/users/?page=${page}&page_size=${pageSize}`);
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
language: string;
|
||||
}): Promise<UserResponse> {
|
||||
return apiFetch<UserResponse>('/users/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string): Promise<UserResponse> {
|
||||
return apiFetch<UserResponse>(`/users/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
return apiFetch<{ status: string }>('/health');
|
||||
}
|
||||
|
||||
export { API_BASE_URL };
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
login as apiLogin,
|
||||
getCurrentUser,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
isAuthenticated,
|
||||
type UserResponse,
|
||||
type ApiError,
|
||||
} from './api';
|
||||
|
||||
export interface AuthState {
|
||||
user: UserResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
if (!isAuthenticated()) {
|
||||
setState({ user: null, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
setState({ user, loading: false, error: null });
|
||||
} catch {
|
||||
clearTokens();
|
||||
setState({ user: null, loading: false, error: 'Session expired' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setState({ user: null, loading: true, error: null });
|
||||
try {
|
||||
const tokens = await apiLogin(email, password);
|
||||
setTokens(tokens);
|
||||
const user = await getCurrentUser();
|
||||
setState({ user, loading: false, error: null });
|
||||
return user;
|
||||
} catch (err) {
|
||||
const message = (err as ApiError)?.error?.message || 'Login failed';
|
||||
setState({ user: null, loading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
setState({ user: null, loading: false, error: null });
|
||||
}, []);
|
||||
|
||||
return { ...state, login, logout, refresh: fetchUser };
|
||||
}
|
||||
|
||||
export function useRequireAuth() {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.loading && !auth.user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [auth.loading, auth.user, router]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface ContactPersonResponse {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
name: string;
|
||||
function?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ContactResponse {
|
||||
id: string;
|
||||
company_name: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role: string;
|
||||
vat_id_status: string;
|
||||
is_private: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
contact_persons: ContactPersonResponse[];
|
||||
}
|
||||
|
||||
export interface ContactCreateData {
|
||||
company_name: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role: string;
|
||||
is_private?: boolean;
|
||||
contact_persons?: ContactPersonCreateData[];
|
||||
}
|
||||
|
||||
export interface ContactUpdateData {
|
||||
company_name?: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country?: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role?: string;
|
||||
is_private?: boolean;
|
||||
}
|
||||
|
||||
export interface ContactPersonCreateData {
|
||||
name: string;
|
||||
function?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ContactListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
role?: string;
|
||||
is_eu?: boolean;
|
||||
is_private?: boolean;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export async function listContacts(params: ContactListParams = {}): Promise<PaginatedResponse<ContactResponse>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.page_size) query.set('page_size', String(params.page_size));
|
||||
if (params.search) query.set('search', params.search);
|
||||
if (params.role) query.set('role', params.role);
|
||||
if (params.is_eu !== undefined) query.set('is_eu', String(params.is_eu));
|
||||
if (params.is_private !== undefined) query.set('is_private', String(params.is_private));
|
||||
if (params.sort) query.set('sort', params.sort);
|
||||
const qs = query.toString();
|
||||
return apiFetch<PaginatedResponse<ContactResponse>>(`/contacts/${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getContact(id: string): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
export async function createContact(data: ContactCreateData): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>('/contacts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateContact(id: string, data: ContactUpdateData): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function addContactPerson(contactId: string, data: ContactPersonCreateData): Promise<ContactPersonResponse> {
|
||||
return apiFetch<ContactPersonResponse>(`/contacts/${contactId}/persons`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeContactPerson(contactId: string, personId: string): Promise<void> {
|
||||
await apiFetch<void>(`/contacts/${contactId}/persons/${personId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate VAT ID format on the frontend (mirrors backend ust_validation.py).
|
||||
* Returns error message or null if valid.
|
||||
*/
|
||||
export function validateVatIdFormat(vatId: string, countryCode: string): string | null {
|
||||
if (!vatId) return null;
|
||||
const normalized = vatId.trim().toUpperCase().replace(/\s+/g, '');
|
||||
const patterns: Record<string, RegExp> = {
|
||||
DE: /^DE\d{9}$/,
|
||||
AT: /^ATU\d{8}$/,
|
||||
FR: /^FR[A-Za-z0-9]{2}\d{9}$/,
|
||||
NL: /^NL\d{9}B\d{2}$/,
|
||||
PL: /^PL\d{10}$/,
|
||||
CZ: /^CZ\d{8,10}$/,
|
||||
IT: /^IT\d{11}$/,
|
||||
ES: /^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$/,
|
||||
BE: /^BE\d{10}$/,
|
||||
DK: /^DK\d{8}$/,
|
||||
SE: /^SE\d{10}$/,
|
||||
};
|
||||
const euCountries = new Set([
|
||||
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR',
|
||||
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
|
||||
'SE', 'SI', 'SK',
|
||||
]);
|
||||
const cc = normalized.substring(0, 2);
|
||||
if (!cc.match(/^[A-Z]{2}$/)) return 'Invalid country code';
|
||||
const pattern = patterns[cc];
|
||||
if (pattern) {
|
||||
return pattern.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
|
||||
}
|
||||
if (euCountries.has(cc)) {
|
||||
return /^[A-Z]{2}[A-Za-z0-9]{5,15}$/.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
|
||||
}
|
||||
return `VAT ID validation not supported for country ${cc}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import deMessages from '@/messages/de.json';
|
||||
import enMessages from '@/messages/en.json';
|
||||
|
||||
export type Locale = 'de' | 'en';
|
||||
|
||||
interface Messages {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const messageMap: Record<Locale, Messages> = {
|
||||
de: deMessages as Messages,
|
||||
en: enMessages as Messages,
|
||||
};
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children, initialLocale = 'de' }: { children: ReactNode; initialLocale?: Locale }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
setLocaleState(newLocale);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string>) => {
|
||||
const messages = messageMap[locale];
|
||||
let message = messages[key] || key;
|
||||
if (params) {
|
||||
for (const [param, value] of Object.entries(params)) {
|
||||
message = message.replace(new RegExp(`\{${param}\}`, 'g'), value);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useI18n must be used within I18nProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface VehicleResponse {
|
||||
id: string;
|
||||
make: string;
|
||||
model: string;
|
||||
fin: string;
|
||||
year?: number;
|
||||
first_registration?: string;
|
||||
power_kw?: number;
|
||||
power_hp?: number;
|
||||
fuel_type?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
condition: string;
|
||||
location?: string;
|
||||
availability: string;
|
||||
price: number;
|
||||
vehicle_type: string;
|
||||
lkw_type?: string;
|
||||
machine_type?: string;
|
||||
body_type?: string;
|
||||
operating_hours?: number;
|
||||
operating_hours_unit?: string;
|
||||
mileage_km?: number;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleCreateData {
|
||||
make: string;
|
||||
model: string;
|
||||
fin: string;
|
||||
year?: number;
|
||||
first_registration?: string;
|
||||
power_kw?: number;
|
||||
fuel_type?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
condition?: string;
|
||||
location?: string;
|
||||
availability?: string;
|
||||
price: number;
|
||||
vehicle_type: string;
|
||||
lkw_type?: string;
|
||||
machine_type?: string;
|
||||
body_type?: string;
|
||||
operating_hours?: number;
|
||||
operating_hours_unit?: string;
|
||||
mileage_km?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface VehicleUpdateData {
|
||||
make?: string;
|
||||
model?: string;
|
||||
fin?: string;
|
||||
year?: number;
|
||||
first_registration?: string;
|
||||
power_kw?: number;
|
||||
fuel_type?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
condition?: string;
|
||||
location?: string;
|
||||
availability?: string;
|
||||
price?: number;
|
||||
vehicle_type?: string;
|
||||
lkw_type?: string;
|
||||
machine_type?: string;
|
||||
body_type?: string;
|
||||
operating_hours?: number;
|
||||
operating_hours_unit?: string;
|
||||
mileage_km?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface VehicleListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
type?: string;
|
||||
availability?: string;
|
||||
min_price?: number;
|
||||
max_price?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface MobileDeStatusResponse {
|
||||
synced: boolean;
|
||||
ad_id?: string;
|
||||
synced_at?: string;
|
||||
sync_status: string;
|
||||
error_log?: string;
|
||||
}
|
||||
|
||||
export interface MobileDePushResponse {
|
||||
message: string;
|
||||
vehicle_id: string;
|
||||
listing_id?: string;
|
||||
}
|
||||
|
||||
export async function listVehicles(params: VehicleListParams = {}): Promise<PaginatedResponse<VehicleResponse>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.page_size) query.set('page_size', String(params.page_size));
|
||||
if (params.type) query.set('type', params.type);
|
||||
if (params.availability) query.set('availability', params.availability);
|
||||
if (params.min_price !== undefined) query.set('min_price', String(params.min_price));
|
||||
if (params.max_price !== undefined) query.set('max_price', String(params.max_price));
|
||||
if (params.search) query.set('search', params.search);
|
||||
if (params.sort) query.set('sort', params.sort);
|
||||
const qs = query.toString();
|
||||
return apiFetch<PaginatedResponse<VehicleResponse>>(`/vehicles/${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getVehicle(id: string): Promise<VehicleResponse> {
|
||||
return apiFetch<VehicleResponse>(`/vehicles/${id}`);
|
||||
}
|
||||
|
||||
export async function createVehicle(data: VehicleCreateData): Promise<VehicleResponse> {
|
||||
return apiFetch<VehicleResponse>('/vehicles/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateVehicle(id: string, data: VehicleUpdateData): Promise<VehicleResponse> {
|
||||
return apiFetch<VehicleResponse>(`/vehicles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteVehicle(id: string): Promise<VehicleResponse> {
|
||||
return apiFetch<VehicleResponse>(`/vehicles/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function pushToMobileDe(vehicleId: string): Promise<MobileDePushResponse> {
|
||||
return apiFetch<MobileDePushResponse>(`/vehicles/${vehicleId}/mobile-de/push`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMobileDeStatus(vehicleId: string): Promise<MobileDeStatusResponse> {
|
||||
return apiFetch<MobileDeStatusResponse>(`/vehicles/${vehicleId}/mobile-de/status`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"login.title": "Anmelden",
|
||||
"login.email": "E-Mail-Adresse",
|
||||
"login.password": "Passwort",
|
||||
"login.submit": "Anmelden",
|
||||
"login.success": "Anmeldung erfolgreich",
|
||||
"login.error.emailRequired": "E-Mail-Adresse ist erforderlich",
|
||||
"login.error.emailInvalid": "Ungültige E-Mail-Adresse",
|
||||
"login.error.passwordRequired": "Passwort ist erforderlich",
|
||||
"login.error.invalidCredentials": "Ungültige E-Mail oder Passwort",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.vehicles": "Fahrzeuge",
|
||||
"nav.contacts": "Kontakte",
|
||||
"nav.sales": "Verkäufe",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.logout": "Abmelden",
|
||||
"common.save": "Speichern",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.delete": "Löschen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.search": "Suchen",
|
||||
"common.loading": "Wird geladen...",
|
||||
"common.error": "Fehler",
|
||||
"common.confirm": "Bestätigen",
|
||||
"user.role.admin": "Administrator",
|
||||
"user.role.verkaeufer": "Verkäufer",
|
||||
"user.role.buchhaltung": "Buchhaltung"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"login.title": "Sign In",
|
||||
"login.email": "Email Address",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Sign In",
|
||||
"login.success": "Login successful",
|
||||
"login.error.emailRequired": "Email address is required",
|
||||
"login.error.emailInvalid": "Invalid email address",
|
||||
"login.error.passwordRequired": "Password is required",
|
||||
"login.error.invalidCredentials": "Invalid email or password",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.vehicles": "Vehicles",
|
||||
"nav.contacts": "Contacts",
|
||||
"nav.sales": "Sales",
|
||||
"nav.settings": "Settings",
|
||||
"nav.logout": "Logout",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.search": "Search",
|
||||
"common.loading": "Loading...",
|
||||
"common.error": "Error",
|
||||
"common.confirm": "Confirm",
|
||||
"user.role.admin": "Administrator",
|
||||
"user.role.verkaeufer": "Sales",
|
||||
"user.role.buchhaltung": "Accounting"
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,11 @@
|
||||
const { NextConfig } = require('next');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+4535
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "erp-nutzfahrzeuge-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"next-intl": "3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4",
|
||||
"@types/node": "20.14.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"tailwindcss": "3.4.7",
|
||||
"postcss": "8.4.40",
|
||||
"autoprefixer": "10.4.19",
|
||||
"vitest": "2.0.5",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
"jsdom": "24.1.1",
|
||||
"@vitejs/plugin-react": "4.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--color-primary)',
|
||||
'primary-hover': 'var(--color-primary-hover)',
|
||||
secondary: 'var(--color-secondary)',
|
||||
background: 'var(--color-background)',
|
||||
surface: 'var(--color-surface)',
|
||||
text: 'var(--color-text)',
|
||||
'text-muted': 'var(--color-text-muted)',
|
||||
error: 'var(--color-error)',
|
||||
success: 'var(--color-success)',
|
||||
border: 'var(--color-border)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { I18nProvider, useI18n } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
// Test Button component
|
||||
describe('Button', () => {
|
||||
it('renders children', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner', () => {
|
||||
render(<Button loading>Submit</Button>);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
fireEvent.click(screen.getByText('Click'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Test Input component
|
||||
describe('Input', () => {
|
||||
it('renders label and input', () => {
|
||||
render(<Input label="Email" placeholder="test@test.com" />);
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('test@test.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(<Input label="Email" error="Invalid email" />);
|
||||
expect(screen.getByText('Invalid email')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Test Card component
|
||||
describe('Card', () => {
|
||||
it('renders title and children', () => {
|
||||
render(<Card title="Test Title"><p>Card content</p></Card>);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Test Toast
|
||||
describe('Toast', () => {
|
||||
it('shows toast on action', async () => {
|
||||
function TestComponent() {
|
||||
const { showToast } = useToast();
|
||||
return (
|
||||
<button onClick={() => showToast('Test message', 'success')} data-testid="trigger">
|
||||
Show Toast
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider>
|
||||
<TestComponent />
|
||||
</ToastProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('trigger'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test i18n
|
||||
describe('i18n', () => {
|
||||
it('translates keys in German', () => {
|
||||
function TestComponent() {
|
||||
const { t } = useI18n();
|
||||
return <span data-testid="translated">{t('login.title')}</span>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="de">
|
||||
<TestComponent />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('translated').textContent).toBe('Anmelden');
|
||||
});
|
||||
|
||||
it('translates keys in English', () => {
|
||||
function TestComponent() {
|
||||
const { t } = useI18n();
|
||||
return <span data-testid="translated">{t('login.title')}</span>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TestComponent />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('translated').textContent).toBe('Sign In');
|
||||
});
|
||||
|
||||
it('switches locale live', () => {
|
||||
function TestComponent() {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="translated">{t('login.submit')}</span>
|
||||
<button data-testid="switch" onClick={() => setLocale(locale === 'de' ? 'en' : 'de')}>
|
||||
Switch
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="de">
|
||||
<TestComponent />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('translated').textContent).toBe('Anmelden');
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
expect(screen.getByTestId('translated').textContent).toBe('Sign In');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nProvider, useI18n } from '@/lib/i18n';
|
||||
|
||||
describe('i18n translation files', () => {
|
||||
it('de.json has at least 20 keys', async () => {
|
||||
const de = await import('@/messages/de.json');
|
||||
const keys = Object.keys(de.default || de);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('en.json has at least 20 keys', async () => {
|
||||
const en = await import('@/messages/en.json');
|
||||
const keys = Object.keys(en.default || en);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('de and en have matching keys', async () => {
|
||||
const de = await import('@/messages/de.json');
|
||||
const en = await import('@/messages/en.json');
|
||||
const deKeys = Object.keys(de.default || de).sort();
|
||||
const enKeys = Object.keys(en.default || en).sort();
|
||||
expect(deKeys).toEqual(enKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n provider', () => {
|
||||
it('provides translation function', () => {
|
||||
function TestComp() {
|
||||
const { t } = useI18n();
|
||||
return <span data-testid="result">{t('common.save')}</span>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="de">
|
||||
<TestComp />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('result').textContent).toBe('Speichern');
|
||||
});
|
||||
|
||||
it('supports parameter interpolation', () => {
|
||||
function TestComp() {
|
||||
const { t } = useI18n();
|
||||
return <span data-testid="result">{t('login.error.invalidCredentials')}</span>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TestComp />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('result').textContent).toBe('Invalid email or password');
|
||||
});
|
||||
|
||||
it('falls back to key for unknown translations', () => {
|
||||
function TestComp() {
|
||||
const { t } = useI18n();
|
||||
return <span data-testid="result">{t('nonexistent.key')}</span>;
|
||||
}
|
||||
render(
|
||||
<I18nProvider initialLocale="de">
|
||||
<TestComp />
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(screen.getByTestId('result').textContent).toBe('nonexistent.key');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
# Test Report - T01: Auth + User Management + RBAC + Base Frontend Layout + i18n
|
||||
|
||||
## Backend Tests
|
||||
|
||||
**Command**: `cd backend && python -m pytest tests/ --cov=app --cov-report=term-missing -v`
|
||||
|
||||
**Result**: 50 passed in 20.40s
|
||||
|
||||
### Coverage
|
||||
| Module | Stmts | Miss | Cover |
|
||||
|--------|-------|------|-------|
|
||||
| app/routers/auth.py | 24 | 0 | 100% |
|
||||
| app/routers/users.py | 35 | 1 | 97% |
|
||||
| app/services/auth_service.py | 86 | 4 | 95% |
|
||||
| **TOTAL** | 359 | 25 | 93% |
|
||||
|
||||
### Test Files
|
||||
- `tests/test_health.py` (3 tests): health check, no-auth, root endpoint
|
||||
- `tests/test_auth.py` (12 tests): login valid/invalid/inactive/nonexistent, refresh valid/invalid/access-rejected, me with/without/invalid/refresh token
|
||||
- `tests/test_users.py` (15 tests): list admin/non-admin/no-auth, pagination, create admin/non-admin/duplicate/short-pw, update admin/nonexistent, delete soft/nonexistent/non-admin, password hash exclusion
|
||||
- `tests/test_auth_service.py` (20 tests): hash/verify, get_by_email/id found/notfound, authenticate valid/wrong/inactive/nonexistent, token pair, refresh valid/invalid/nonexistent, create success/duplicate, list pagination, update success/notfound/role, deactivate success/notfound
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
**Command**: `cd frontend && npx vitest run`
|
||||
|
||||
**Result**: 16 passed in 2.15s
|
||||
|
||||
### Test Files
|
||||
- `tests/auth.test.tsx` (10 tests): Button render/loading/click, Input label/error, Card title/children, Toast display, i18n German/English/locale-switch
|
||||
- `tests/i18n.test.tsx` (6 tests): de.json ≥20 keys, en.json ≥20 keys, matching keys, translation function, parameter interpolation, fallback for unknown keys
|
||||
|
||||
## TypeScript Check
|
||||
|
||||
**Command**: `cd frontend && npx tsc --noEmit`
|
||||
|
||||
**Result**: Exit 0, no errors
|
||||
|
||||
## Smoke Test
|
||||
|
||||
- Backend: FastAPI app starts, health endpoint returns {"status":"ok"}, login endpoint accepts credentials and returns JWT tokens
|
||||
- Frontend: Login page renders with email/password inputs and submit button, Toast notifications work on error, i18n switches between DE/EN live
|
||||
- Database: PostgreSQL test DB (erp_test) created, tables auto-created/dropped per test, all async operations work correctly
|
||||
- Auth: bcrypt password hashing works, JWT access (15min) + refresh (7d) tokens generated and verified, RBAC enforces admin-only on /users endpoints
|
||||
Reference in New Issue
Block a user