docs: vollständige Projektdokumentation hinzugefügt
- docs/api.md: komplette API-Referenz (1375 Zeilen, alle Endpunkte) - docs/architecture.md: Tech-Stack, DB-Schema, RLS-Architektur, Auth-Flow - docs/deployment.md: Setup, nginx, systemd, update.sh, Backup/Rollback - docs/development.md: Dev-Setup, Test-Workflow, Code-Konventionen, Fallstricke Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
# TimeMaster – Developer-Guide
|
||||
|
||||
Stand: 2026-05-24
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungsumgebung aufsetzen
|
||||
|
||||
### Voraussetzungen (lokal)
|
||||
|
||||
- Python 3.12+
|
||||
- Node.js 20+ und npm
|
||||
- SSH-Zugang zu `root@192.168.1.137` (Tests und Datenbank laufen dort)
|
||||
- Git-Zugang zu `gitea.perlbach24.de/scripte/timemaster.git`
|
||||
|
||||
Lokale PostgreSQL oder Redis-Instanz ist **nicht** erforderlich. Alle Backend-Operationen laufen auf dem Entwicklungsserver via SSH.
|
||||
|
||||
### Repository klonen
|
||||
|
||||
```bash
|
||||
git clone git@gitea.perlbach24.de:scripte/timemaster.git
|
||||
cd timemaster
|
||||
```
|
||||
|
||||
### Frontend lokal einrichten
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.example .env.local # falls vorhanden, sonst:
|
||||
# Inhalt von .env.local:
|
||||
# VITE_API_URL=http://192.168.1.137:8000
|
||||
npm run dev # Vite Dev-Server auf localhost:5173
|
||||
```
|
||||
|
||||
Das Frontend spricht dann direkt gegen den Backend-Server 137. Für Produktions-Builds:
|
||||
|
||||
```bash
|
||||
npm run build # Output in frontend/dist/
|
||||
```
|
||||
|
||||
### Backend: kein lokales Setup nötig
|
||||
|
||||
Backend-Dateien werden bearbeitet und per `update.sh` auf den Server synchronisiert. Tests laufen remote (siehe Abschnitt Tests).
|
||||
|
||||
---
|
||||
|
||||
## Tests ausführen
|
||||
|
||||
Tests laufen ausschließlich auf `root@192.168.1.137`. Nie lokal – die Tests benötigen eine PostgreSQL-Instanz mit der `timemaster_test`-Datenbank sowie Redis.
|
||||
|
||||
```bash
|
||||
# Alle Tests
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v"
|
||||
|
||||
# Schnell (bricht beim ersten Fehler ab)
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -x -q"
|
||||
|
||||
# Einzelne Testdatei
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py"
|
||||
|
||||
# Einzelner Test
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && pytest -v tests/test_absences.py::test_quick_sick"
|
||||
```
|
||||
|
||||
### pytest-Konfiguration
|
||||
|
||||
`pytest.ini` / `pyproject.toml` enthält:
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
```
|
||||
|
||||
Alle Fixtures in `conftest.py` haben `scope="session"` und `loop_scope="session"`. Das ist zwingend erforderlich für pytest-asyncio 1.x mit asyncpg – ein einzelner Event-Loop wird für die gesamte Test-Session geteilt.
|
||||
|
||||
### Wichtige Fixtures in conftest.py
|
||||
|
||||
```python
|
||||
setup_db # scope=session, autouse=True
|
||||
# Legt Test-DB-Schema neu an (DROP+CREATE SCHEMA public)
|
||||
# Wendet alle RLS-Policies an (identisch zu Migration 0024)
|
||||
|
||||
db_session # scope=session
|
||||
# Gemeinsame AsyncSession für alle Tests
|
||||
|
||||
client # scope=session
|
||||
# httpx AsyncClient mit ASGI-Transport (kein echter HTTP-Stack)
|
||||
# Override get_db → nutzt db_session
|
||||
# Rate-Limiter wird deaktiviert
|
||||
|
||||
registered_user # scope=session
|
||||
# Legt einmalig eine Firma "Test GmbH" + Admin-User an
|
||||
# Gibt { tokens, user } zurück
|
||||
```
|
||||
|
||||
### RLS in Tests
|
||||
|
||||
Die Test-DB-Session setzt `bypass_rls = 'on'` – alle Test-Queries sind damit nicht durch RLS eingeschränkt. Das entspricht dem Verhalten von unauthentifizierten Routen im Produktivbetrieb. Tests, die RLS-Verhalten prüfen wollen, müssen `app.company_id` explizit setzen.
|
||||
|
||||
---
|
||||
|
||||
## Neuen API-Endpunkt anlegen
|
||||
|
||||
### Schritt 1: Schema (Pydantic)
|
||||
|
||||
Datei: `backend/app/schemas/<modul>.py`
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, UUID4
|
||||
from datetime import datetime
|
||||
|
||||
class MyResourceCreate(BaseModel):
|
||||
name: str
|
||||
company_id: UUID4 # bei manuellen Creates; bei auth-geschützten Endpunkten aus current_user
|
||||
|
||||
class MyResourceOut(BaseModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
```
|
||||
|
||||
Pydantic v2: `model_validate(obj)` statt `.from_orm(obj)`. `from_attributes = True` aktiviert ORM-Mode.
|
||||
|
||||
### Schritt 2: Model (SQLAlchemy)
|
||||
|
||||
Datei: `backend/app/models/<modul>.py`
|
||||
|
||||
```python
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
class MyResource(Base):
|
||||
__tablename__ = "my_resources"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"))
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
```
|
||||
|
||||
Model in `backend/app/models/__init__.py` importieren.
|
||||
|
||||
### Schritt 3: Service
|
||||
|
||||
Datei: `backend/app/services/<modul>_service.py`
|
||||
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import HTTPException
|
||||
from app.models.my_resource import MyResource
|
||||
from app.schemas.my_module import MyResourceCreate
|
||||
|
||||
class MyService:
|
||||
async def create(self, data: MyResourceCreate, company_id: UUID, db: AsyncSession) -> MyResource:
|
||||
obj = MyResource(company_id=company_id, name=data.name)
|
||||
db.add(obj)
|
||||
await db.flush() # flush statt commit – commit macht get_db() nach yield
|
||||
return obj
|
||||
|
||||
async def get_all(self, company_id: UUID, db: AsyncSession) -> list[MyResource]:
|
||||
result = await db.execute(
|
||||
select(MyResource).where(MyResource.company_id == company_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
my_service = MyService()
|
||||
```
|
||||
|
||||
**Regel:** Kein `db.commit()` in Services. `get_db()` committed nach `yield`. Nur `db.flush()` in Services, wenn Werte (z.B. generierte IDs) vor dem finalen Commit benötigt werden.
|
||||
|
||||
### Schritt 4: Router
|
||||
|
||||
Datei: `backend/app/routers/<modul>.py`
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import CurrentUser, require_role
|
||||
from app.models.user import UserRole
|
||||
from app.schemas.my_module import MyResourceCreate, MyResourceOut
|
||||
from app.services.my_service import my_service
|
||||
|
||||
router = APIRouter(prefix="/my-resources", tags=["MyResources"])
|
||||
|
||||
@router.get("/", response_model=list[MyResourceOut])
|
||||
async def list_resources(
|
||||
current_user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await my_service.get_all(current_user.company_id, db)
|
||||
|
||||
@router.post("/", response_model=MyResourceOut, status_code=201)
|
||||
async def create_resource(
|
||||
data: MyResourceCreate,
|
||||
current_user: CurrentUser = Depends(require_role(UserRole.COMPANY_ADMIN, UserRole.HR)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await my_service.create(data, current_user.company_id, db)
|
||||
```
|
||||
|
||||
Router in `backend/app/main.py` registrieren:
|
||||
|
||||
```python
|
||||
from app.routers import my_module
|
||||
app.include_router(my_module.router, prefix=API_PREFIX)
|
||||
```
|
||||
|
||||
### Schritt 5: Tests
|
||||
|
||||
Datei: `backend/tests/test_<modul>.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_resource(client: AsyncClient, registered_user):
|
||||
headers = {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"}
|
||||
resp = await client.post("/api/v1/my-resources/", json={"name": "Test"}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Test"
|
||||
assert "id" in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Neue Alembic-Migration anlegen
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && \
|
||||
alembic revision --autogenerate -m 'add_my_feature'"
|
||||
```
|
||||
|
||||
Die generierte Datei in `backend/migrations/versions/` erscheint auf dem Server. Sie muss dann lokal per `git pull` geholt werden (oder die Datei manuell kopieren).
|
||||
|
||||
**Wichtig:** Autogenerate erkennt nicht alles zuverlässig. Immer die generierte Datei prüfen, bevor sie eingecheckt wird. Besonders:
|
||||
- Partial Unique Indexes (müssen manuell mit `postgresql_where` Klausel ergänzt werden)
|
||||
- CHECK Constraints
|
||||
- RLS-Policies (nie autogeneriert – immer manuell, wie in 0024 gezeigt)
|
||||
- Enum-Änderungen
|
||||
|
||||
Migrationsdatei-Namenskonvention: `XXXX_kurzbeschreibung.py` (fortlaufend, nächste: `0025_...`).
|
||||
|
||||
Migration testen:
|
||||
```bash
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
|
||||
# Rollback prüfen:
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic downgrade -1"
|
||||
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Neue Frontend-Seite anlegen
|
||||
|
||||
### 1. Page-Komponente
|
||||
|
||||
Datei: `frontend/src/pages/MyFeaturePage.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Layout from '../components/Layout'
|
||||
|
||||
const MyFeaturePage: React.FC = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Mein Feature</h1>
|
||||
{/* Inhalt */}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyFeaturePage
|
||||
```
|
||||
|
||||
### 2. Route registrieren
|
||||
|
||||
`frontend/src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
import MyFeaturePage from './pages/MyFeaturePage'
|
||||
|
||||
// In der Router-Konfiguration:
|
||||
<Route path="/my-feature" element={
|
||||
<ProtectedRoute roles={['COMPANY_ADMIN', 'HR']}>
|
||||
<MyFeaturePage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### 3. Navigation in Layout.tsx
|
||||
|
||||
`frontend/src/components/Layout.tsx` – Navigation-Links in der Sidebar oder im Settings-Dropdown ergänzen. Sichtbarkeit über `currentUser.role` steuern.
|
||||
|
||||
### 4. API-Calls
|
||||
|
||||
`frontend/src/api/myFeature.ts`:
|
||||
|
||||
```typescript
|
||||
import axios from './client' // konfigurierter Axios-Client mit Bearer-Token
|
||||
|
||||
export const getMyResources = () =>
|
||||
axios.get('/api/v1/my-resources/').then(r => r.data)
|
||||
|
||||
export const createMyResource = (data: { name: string }) =>
|
||||
axios.post('/api/v1/my-resources/', data).then(r => r.data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code-Konventionen
|
||||
|
||||
### Backend
|
||||
|
||||
**Async überall:** Alle DB-Operationen nutzen `AsyncSession`. Kein Mischen von sync/async SQLAlchemy.
|
||||
|
||||
```python
|
||||
# Korrekt
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Falsch
|
||||
user = db.query(User).filter_by(email=email).first() # sync API
|
||||
```
|
||||
|
||||
**Fehler als HTTPException:**
|
||||
|
||||
```python
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
```
|
||||
|
||||
Kein `raise ValueError()` oder `return None` aus Routers. Services dürfen HTTPException werfen, wenn der Fehler inhärent HTTP-natur hat (z.B. Duplikat-Fehler bei eindeutigem Feld).
|
||||
|
||||
**Tokens nie im Klartext in DB:**
|
||||
|
||||
```python
|
||||
from app.core.security import hash_token
|
||||
db_token = hash_token(raw_token) # SHA-256
|
||||
```
|
||||
|
||||
**Kein Hard-Delete bei Users:**
|
||||
|
||||
```python
|
||||
user.is_active = False # Deaktivierung
|
||||
# Nicht: await db.delete(user)
|
||||
```
|
||||
|
||||
**Pydantic v2:**
|
||||
|
||||
```python
|
||||
# Serialisierung
|
||||
out = MyResourceOut.model_validate(orm_obj)
|
||||
|
||||
# Nicht mehr:
|
||||
out = MyResourceOut.from_orm(orm_obj) # Pydantic v1 Syntax
|
||||
```
|
||||
|
||||
**AuditLog schreiben bei sensitiven Aktionen:**
|
||||
|
||||
```python
|
||||
from app.models.audit_log import AuditLog
|
||||
audit = AuditLog(
|
||||
company_id=current_user.company_id,
|
||||
user_id=current_user.id,
|
||||
action="user.role_changed",
|
||||
entity_type="user",
|
||||
entity_id=str(target_user.id),
|
||||
old_value={"role": old_role},
|
||||
new_value={"role": new_role},
|
||||
ip_address=request.client.host,
|
||||
)
|
||||
db.add(audit)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
- TypeScript strict – keine `any`-Typen
|
||||
- Tailwind CSS für Styling – keine CSS-Module oder Styled Components
|
||||
- Formular-Validierung inline oder mit React Hook Form
|
||||
- Axios-Client aus `api/client.ts` verwenden (handhabt Token-Refresh automatisch)
|
||||
- Fehlermeldungen aus `error.response?.data?.detail` extrahieren
|
||||
|
||||
---
|
||||
|
||||
## ArbZG-Regeln (Arbeitszeitgesetz)
|
||||
|
||||
Implementiert in `backend/app/services/time_service.py`:
|
||||
|
||||
| Bedingung | Regel | Reaktion |
|
||||
|-----------|-------|----------|
|
||||
| Arbeitszeit > 8h (480 min) | Maximalarbeitszeit überschritten | Warnung |
|
||||
| Arbeitszeit > 10h (600 min) | Absolutes Maximum | Warnung + Flag |
|
||||
| Arbeitszeit >= 6h, Pause < 30 min | Pflichtpause ab 6h fehlt | Warnung |
|
||||
| Arbeitszeit >= 9h, Pause < 45 min | Pflichtpause ab 9h fehlt | Warnung |
|
||||
| Zeit zwischen Schichtende und nächstem Beginn < 11h | Ruhezeit unterschritten | Warnung |
|
||||
|
||||
Warnungen werden in `time_entries.arbzg_warning` gespeichert. Es gibt kein hartes Blockieren – Manager und HR sehen die Warnungen und können reagieren.
|
||||
|
||||
---
|
||||
|
||||
## RLS-Entwicklungshinweise
|
||||
|
||||
### db.refresh() nach commit() ist verboten
|
||||
|
||||
```python
|
||||
# FALSCH – führt zu Fehler oder leerem Ergebnis wegen RLS-Kontext-Verlust
|
||||
await db.commit()
|
||||
await db.refresh(obj) # zweiter SELECT außerhalb Transaktionskontext
|
||||
|
||||
# RICHTIG – Werte vor commit() sichern mit flush()
|
||||
await db.flush() # schreibt in DB, commit noch ausstehend
|
||||
obj_id = obj.id # ID jetzt verfügbar (durch flush zugewiesen)
|
||||
# Werte, die nach commit() gebraucht werden, vor dem commit() lesen
|
||||
await db.commit()
|
||||
# obj.id ist immer noch verfügbar wegen expire_on_commit=False
|
||||
```
|
||||
|
||||
### expire_on_commit=False
|
||||
|
||||
Die Session-Factory ist mit `expire_on_commit=False` konfiguriert. Das bedeutet: Attribute werden nach einem commit() nicht als "expired" markiert. Bereits geladene Werte bleiben im Python-Objekt erhalten. Ein erneuter SELECT (durch Attributzugriff nach commit) findet nicht statt.
|
||||
|
||||
### SET LOCAL gilt nur für die aktuelle Transaktion
|
||||
|
||||
```python
|
||||
# In get_db():
|
||||
await session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
yield session
|
||||
# Nach yield: commit → Transaktion endet → SET LOCAL wird zurückgesetzt
|
||||
```
|
||||
|
||||
Bei neuen Requests beginnt immer eine frische Transaktion mit `bypass_rls = 'on'`. `get_current_user()` setzt dann ggf. `bypass_rls = 'off'` und `company_id`.
|
||||
|
||||
### SUPER_ADMIN und company_id = None
|
||||
|
||||
SUPER_ADMIN hat `company_id = None`. Für diesen User bleibt `bypass_rls = 'on'`. Services müssen prüfen, ob ein SUPER_ADMIN-Request eine `company_id` als Query-Parameter übergibt, wenn er firmenbezogene Daten abfragt.
|
||||
|
||||
---
|
||||
|
||||
## Git-Workflow
|
||||
|
||||
### Branch-Strategie
|
||||
|
||||
Alle Änderungen landen auf `main`. Kein Feature-Branch-Workflow in Phase 1.
|
||||
|
||||
```bash
|
||||
# Vor jeder Änderung: aktuellen Stand holen
|
||||
git pull origin main
|
||||
|
||||
# Änderungen committen
|
||||
git add -p # interaktiv (oder git add <datei>)
|
||||
git commit -m "feat: kurze Beschreibung was geändert wurde"
|
||||
|
||||
# Auf Gitea pushen
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Commit-Messages folgen Conventional Commits:
|
||||
- `feat:` neues Feature
|
||||
- `fix:` Bugfix
|
||||
- `refactor:` Code-Umstrukturierung ohne Verhaltensänderung
|
||||
- `test:` Testzusätze/-korrekturen
|
||||
- `chore:` Wartungsarbeiten (Dependencies, Konfiguration)
|
||||
|
||||
### Deployment nach Push
|
||||
|
||||
```bash
|
||||
./update.sh # deployt auf beide Server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Fallstricke
|
||||
|
||||
### Saturday/Sunday in Tests
|
||||
|
||||
Abwesenheits-Berechnungen überspringen Wochenenden. Wenn ein Test-Datum auf ein Wochenende fällt, ist `working_days = 0`. Testdaten immer mit Wochentagen (Mo-Fr) anlegen oder dynamisch berechnen:
|
||||
|
||||
```python
|
||||
from datetime import date, timedelta
|
||||
|
||||
def next_monday():
|
||||
today = date.today()
|
||||
days_ahead = 7 - today.weekday() # 0=Monday
|
||||
return today + timedelta(days=days_ahead % 7 or 7)
|
||||
```
|
||||
|
||||
### Self-Approval
|
||||
|
||||
Ein User kann seinen eigenen Abwesenheitsantrag nicht genehmigen. Der `absence_service` prüft `approved_by != user_id`. In Tests muss ein separater User mit MANAGER/HR-Rolle für Genehmigungen angelegt werden.
|
||||
|
||||
### RLS-Kontext nach commit()
|
||||
|
||||
Wie oben beschrieben: `db.refresh(obj)` nach `db.commit()` schlägt fehl oder liefert leere Ergebnisse, weil der RLS-Kontext (`SET LOCAL`) transaktionsgebunden ist und nach dem commit() zurückgesetzt wurde. Statt dessen:
|
||||
|
||||
1. `db.flush()` vor dem commit() für sofortige ID-Zuweisung
|
||||
2. Benötigte Attribute vor dem commit() lesen
|
||||
3. `expire_on_commit=False` stellt sicher, dass bereits geladene Attribute erhalten bleiben
|
||||
|
||||
### LDAP-Authentifizierung und Tests
|
||||
|
||||
LDAP-Tests benötigen einen erreichbaren LDAP-Server oder müssen gemockt werden. Standardmäßig sind LDAP-Tests in der Test-Suite mit `pytest.mark.skip` oder durch fehlende Konfig deaktiviert.
|
||||
|
||||
### Alembic-Kette und Migration 0017
|
||||
|
||||
Migration 0017 existiert nicht – die Nummer wurde übersprungen. Die Alembic-Kette ist: `...0016 → 0018 → 0019 → 0020 → 0022 → 0023 → 0024`. Neue Migrationen chained auf `0024`.
|
||||
|
||||
### Bradford-Faktor Berechnung
|
||||
|
||||
Der Bradford-Faktor (`S² × D`) nutzt ein rollendes 12-Monats-Fenster ab `ref_date`. `ref_date` ist optional und defaulted auf `date.today()`. Bei Tests immer ein explizites `ref_date` übergeben, da sich sonst der Test-Zeitraum mit der Zeit verschiebt.
|
||||
|
||||
### Rate-Limiter in Tests
|
||||
|
||||
`limiter.enabled = False` wird in `conftest.py` gesetzt. Falls ein neuer Router Rate-Limiting hinzufügt, ist das in Tests automatisch deaktiviert. In manuellen Integration-Tests gegen den echten Server gelten die Limits.
|
||||
|
||||
### Passwort-Validierung
|
||||
|
||||
Min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer. Diese Regel ist in `security.py` implementiert und gilt für Register, Invite-Accept und Change-Password. Schwache Test-Passwörter wie `password123` scheitern – mindestens `Password1` oder `Secret123` verwenden.
|
||||
Reference in New Issue
Block a user