fix(users): repair create delete and duplicate email handling

This commit is contained in:
Schubert Ferenc 2026-07-11 17:14:41 +02:00
parent 155fdbb16a
commit 0bbcaba211
22 changed files with 773 additions and 107 deletions

View file

@ -0,0 +1,108 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110006"
down_revision = "202607110005"
branch_labels = None
depends_on = None
ALLOWED_ROLES = ("admin", "pruefer", "mitarbeiter", "leser")
def _other_columns_use_userrole(connection) -> bool:
result = connection.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_type t ON t.oid = a.atttypid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE t.typname = 'userrole'
AND NOT (n.nspname = 'public' AND c.relname = 'users' AND a.attname = 'role')
AND a.attnum > 0
AND NOT a.attisdropped
)
"""
)
)
return bool(result.scalar())
def _normalize_roles(connection) -> None:
connection.execute(
sa.text(
"""
UPDATE users
SET role = CASE upper(role::text)
WHEN 'ADMIN' THEN 'admin'
WHEN 'PRUEFER' THEN 'pruefer'
WHEN 'EMPLOYEE' THEN 'mitarbeiter'
WHEN 'MITARBEITER' THEN 'mitarbeiter'
WHEN 'AUDITOR' THEN 'leser'
WHEN 'LESER' THEN 'leser'
ELSE role::text
END
"""
)
)
invalid_roles = connection.execute(
sa.text(
"""
SELECT DISTINCT role::text
FROM users
WHERE role::text NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
"""
),
).scalars().all()
if invalid_roles:
raise RuntimeError(f"Unknown user roles encountered during migration: {', '.join(sorted(invalid_roles))}")
def upgrade() -> None:
connection = op.get_bind()
op.alter_column(
"users",
"role",
existing_type=sa.Enum(name="userrole"),
type_=sa.String(length=50),
existing_nullable=False,
existing_server_default=None,
postgresql_using="role::text",
nullable=False,
)
_normalize_roles(connection)
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'"))
if not _other_columns_use_userrole(connection):
op.execute(sa.text("DROP TYPE IF EXISTS userrole"))
def downgrade() -> None:
connection = op.get_bind()
op.execute(
sa.text(
"CREATE TYPE userrole AS ENUM ('admin', 'pruefer', 'mitarbeiter', 'leser')"
)
)
invalid_roles = connection.execute(
sa.text(
"""
SELECT DISTINCT role
FROM users
WHERE role NOT IN ('admin', 'pruefer', 'mitarbeiter', 'leser')
"""
),
).scalars().all()
if invalid_roles:
raise RuntimeError(f"Cannot downgrade user roles with invalid values: {', '.join(sorted(invalid_roles))}")
op.alter_column(
"users",
"role",
existing_type=sa.String(length=50),
type_=sa.Enum("admin", "pruefer", "mitarbeiter", "leser", name="userrole"),
nullable=False,
)
op.execute(sa.text("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'mitarbeiter'::userrole"))

View file

@ -0,0 +1,25 @@
"""add deleted_at to users for soft delete
Revision ID: 202607110007
Revises: 202607110006
Create Date: 2026-07-11 00:07:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110007"
down_revision = "202607110006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("users", "deleted_at")

View file

@ -6,13 +6,14 @@ import json
import logging import logging
import shutil import shutil
import uuid import uuid
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.dependencies import current_admin, current_user from app.api.dependencies import current_admin, current_user
@ -560,7 +561,7 @@ def _user_payload(user: User) -> dict:
"email": user.email, "email": user.email,
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"role": user.role, "role": UserRole(user.role),
"is_active": user.is_active, "is_active": user.is_active,
"must_change_password": user.must_change_password, "must_change_password": user.must_change_password,
"last_login_at": user.last_login_at, "last_login_at": user.last_login_at,
@ -581,6 +582,7 @@ def list_users(
_: User = Depends(current_admin), _: User = Depends(current_admin),
): ):
query = select(User) query = select(User)
query = query.where(User.deleted_at.is_(None))
if search: if search:
term = f"%{search.strip()}%" term = f"%{search.strip()}%"
query = query.where( query = query.where(
@ -605,8 +607,9 @@ def list_users(
sort_column = sort_columns.get(sort_by, User.created_at) sort_column = sort_columns.get(sort_by, User.created_at)
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc()) query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
pages = max((total + page_size - 1) // page_size, 1)
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size))) items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
return {"items": items, "total": total, "page": page, "page_size": page_size} return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
@router.post("/users", status_code=201) @router.post("/users", status_code=201)
@ -615,9 +618,17 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _:
import secrets import secrets
from app.models.user import User from app.models.user import User
normalized_email = payload.email.strip().lower()
logger.debug("create_user request email=%s normalized_email=%s", payload.email, normalized_email)
existing = session.scalar(select(User).where(func.lower(User.email) == normalized_email))
logger.debug("create_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
temporary_password = payload.temporary_password or secrets.token_urlsafe(12) temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
try:
user = User( user = User(
email=payload.email.lower(), email=normalized_email,
first_name=payload.first_name, first_name=payload.first_name,
last_name=payload.last_name, last_name=payload.last_name,
role=payload.role.value, role=payload.role.value,
@ -625,16 +636,29 @@ def create_user(payload: UserCreate, session: Session = Depends(get_session), _:
is_active=payload.is_active, is_active=payload.is_active,
must_change_password=payload.must_change_password, must_change_password=payload.must_change_password,
) )
logger.debug("create_user insert email=%s", normalized_email)
session.add(user) session.add(user)
session.commit() session.commit()
logger.debug("create_user commit ok email=%s id=%s", normalized_email, user.id)
session.refresh(user) session.refresh(user)
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201) logger.debug("create_user response email=%s status=201", normalized_email)
return JSONResponse(
content={"temporary_password": temporary_password, "user": jsonable_encoder(user)},
status_code=201,
)
except IntegrityError as exc:
session.rollback()
logger.exception("create_user integrity error email=%s", normalized_email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.get("/users/{item_id}", response_model=UserRead) @router.get("/users/{item_id}", response_model=UserRead)
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)): def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id) user = session.get(User, item_id)
if user is None: if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
return user return user
@ -642,7 +666,7 @@ def get_user(item_id: str, session: Session = Depends(get_session), _: User = De
@router.put("/users/{item_id}", response_model=UserRead) @router.put("/users/{item_id}", response_model=UserRead)
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)): def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id) user = session.get(User, item_id)
if user is None: if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id and not payload.is_active: if user.id == me.id and not payload.is_active:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated") raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
@ -651,44 +675,58 @@ def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(ge
) or 0 ) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value): if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.") raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
try:
normalized_email = payload.email.strip().lower()
logger.debug("update_user request id=%s email=%s normalized_email=%s", item_id, payload.email, normalized_email)
existing = session.scalar(
select(User).where(func.lower(User.email) == normalized_email, User.id != user.id)
)
logger.debug("update_user select result=%s count=%s", bool(existing), 1 if existing else 0)
if existing is not None:
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.")
user.first_name = payload.first_name user.first_name = payload.first_name
user.last_name = payload.last_name user.last_name = payload.last_name
user.email = payload.email.lower() user.email = normalized_email
user.role = payload.role.value user.role = payload.role.value
user.is_active = payload.is_active user.is_active = payload.is_active
user.must_change_password = payload.must_change_password user.must_change_password = payload.must_change_password
logger.debug("update_user commit start id=%s email=%s", user.id, normalized_email)
session.commit() session.commit()
logger.debug("update_user commit ok id=%s email=%s", user.id, normalized_email)
session.refresh(user) session.refresh(user)
return user return user
except IntegrityError as exc:
session.rollback()
logger.exception("update_user integrity error id=%s email=%s", item_id, payload.email)
raise HTTPException(status_code=409, detail="Die E-Mail-Adresse wird bereits verwendet.") from exc
except ProgrammingError as exc:
session.rollback()
raise HTTPException(status_code=422, detail="Die Benutzerrolle ist ungültig.") from exc
@router.post("/users/{item_id}/deactivate", response_model=UserRead) @router.delete("/users/{item_id}", status_code=204)
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)): def delete_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
user = session.get(User, item_id) user = session.get(User, item_id)
if user is None: if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
if user.id == me.id: if user.id == me.id:
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated") raise HTTPException(status_code=409, detail="Das eigene Benutzerkonto kann nicht geloescht werden.")
active_admins = session.scalar( active_admins = session.scalar(
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True)) select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
) or 0 ) or 0
if user.role == UserRole.ADMIN.value and active_admins <= 1: if user.role == UserRole.ADMIN.value and active_admins <= 1:
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.") raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht geloescht werden.")
referenced_validations = session.scalar(
select(func.count()).select_from(Validation).where(Validation.examiner_id == user.id)
) or 0
if referenced_validations == 0:
session.delete(user)
session.commit()
return Response(status_code=204)
user.is_active = False user.is_active = False
user.deleted_at = datetime.now(UTC)
session.commit() session.commit()
session.refresh(user) return Response(status_code=204)
return user
@router.post("/users/{item_id}/activate", response_model=UserRead)
def activate_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
user = session.get(User, item_id)
if user is None:
raise HTTPException(status_code=404, detail="Resource not found")
user.is_active = True
session.commit()
session.refresh(user)
return user
@router.post("/users/{item_id}/reset-password") @router.post("/users/{item_id}/reset-password")
@ -697,7 +735,7 @@ def reset_user_password(item_id: str, session: Session = Depends(get_session), _
from app.core.security import hash_password from app.core.security import hash_password
user = session.get(User, item_id) user = session.get(User, item_id)
if user is None: if user is None or user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
temporary_password = secrets.token_urlsafe(12) temporary_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(temporary_password) user.password_hash = hash_password(temporary_password)

View file

@ -4,7 +4,7 @@ import enum
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, String from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, validates
from app.db.base import Base, TimestampMixin, UUIDMixin from app.db.base import Base, TimestampMixin, UUIDMixin
@ -20,15 +20,21 @@ class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users" __tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
full_name: Mapped[str] = mapped_column(String(160), nullable=False)
first_name: Mapped[str] = mapped_column(String(80)) first_name: Mapped[str] = mapped_column(String(80))
last_name: Mapped[str] = mapped_column(String(80)) last_name: Mapped[str] = mapped_column(String(80))
role: Mapped[str] = mapped_column(String(40), default=UserRole.MITARBEITER.value) role: Mapped[str] = mapped_column(String(50), nullable=False, default=UserRole.MITARBEITER.value)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@property @validates("first_name", "last_name")
def full_name(self) -> str: def _sync_full_name(self, key: str, value: str) -> str:
return f"{self.first_name} {self.last_name}".strip() first_name = value if key == "first_name" else getattr(self, "first_name", None)
last_name = value if key == "last_name" else getattr(self, "last_name", None)
if first_name is not None and last_name is not None:
self.full_name = f"{first_name} {last_name}".strip()
return value

View file

@ -18,7 +18,7 @@ class UserRepository(Repository[User]):
model = User model = User
def by_email(self, email: str) -> User | None: def by_email(self, email: str) -> User | None:
return self.session.scalar(select(User).where(User.email == email.lower())) return self.session.scalar(select(User).where(User.email == email.strip().lower()))
class CustomerRepository(Repository[Customer]): class CustomerRepository(Repository[Customer]):

View file

@ -23,3 +23,4 @@ class PaginatedResponse(BaseModel, Generic[T]):
total: int total: int
page: int page: int
page_size: int page_size: int
pages: int

View file

@ -23,7 +23,7 @@ class AuthService:
detail="Invalid credentials", detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_active: if not user.is_active or user.deleted_at is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
user.last_login_at = datetime.now(UTC) user.last_login_at = datetime.now(UTC)
self.users.session.commit() self.users.session.commit()

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
import pytest
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -19,8 +21,9 @@ from app.services.validation_workflow import ValidationWorkflowService
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.reference_masterdata import ReferenceMasterdataImportService from app.services.reference_masterdata import ReferenceMasterdataImportService
from app.api.v1.auth import login as auth_login from app.api.v1.auth import login as auth_login
from app.api.v1 import domain as domain_api
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.domain import ValidationCreate from app.schemas.domain import UserCreate, UserUpdate, ValidationCreate
from app.modules.orion.service import OrionReportService from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
@ -212,6 +215,319 @@ def test_user_model_supports_password_management_fields():
assert user.role == UserRole.PRUEFER assert user.role == UserRole.PRUEFER
def test_admin_appears_in_user_list():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add(admin)
db.flush()
result = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
assert result["total"] == 1
assert result["pages"] == 1
assert any(item.id == admin.id for item in result["items"])
def test_user_list_pagination_and_filters_work():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
inactive = User(
email="inactive@schubamed.de",
first_name="Inactive",
last_name="User",
role=UserRole.LESER.value,
password_hash="hash",
is_active=False,
must_change_password=False,
)
db.add_all([admin, inactive])
db.flush()
result = domain_api.list_users(session=db, _=admin, page=1, page_size=1, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
assert result["total"] == 2
assert result["pages"] == 2
assert len(result["items"]) == 1
active_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=True, sort_by="created_at", sort_order="desc")
assert active_only["total"] == 1
assert all(item.is_active for item in active_only["items"])
inactive_only = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=False, sort_by="created_at", sort_order="desc")
assert inactive_only["total"] == 1
assert all(not item.is_active for item in inactive_only["items"])
role_filtered = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=UserRole.ADMIN.value, active=None, sort_by="created_at", sort_order="desc")
assert role_filtered["total"] == 1
assert role_filtered["items"][0].role == UserRole.ADMIN.value
def test_admin_can_create_users_with_supported_roles():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add(admin)
db.flush()
for role in (UserRole.MITARBEITER, UserRole.PRUEFER, UserRole.LESER):
response = domain_api.create_user(
UserCreate(
email=f"{role.value}@schubamed.de",
first_name="Test",
last_name="User",
role=role,
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
),
session=db,
_=admin,
)
payload = json.loads(response.body)
assert response.status_code == 201
assert payload["user"]["role"] == role.value
assert db.scalar(select(User).where(User.email == f"{role.value}@schubamed.de")) is not None
def test_create_user_trims_and_normalizes_email():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add(admin)
db.flush()
response = domain_api.create_user(
UserCreate(
email=" New.User@Schubamed.DE ",
first_name="New",
last_name="User",
role=UserRole.MITARBEITER,
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
),
session=db,
_=admin,
)
payload = json.loads(response.body)
assert response.status_code == 201
assert payload["user"]["email"] == "new.user@schubamed.de"
assert db.scalar(select(User).where(User.email == "new.user@schubamed.de")) is not None
def test_duplicate_email_returns_409_and_rolls_back_session():
from fastapi import HTTPException
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add(admin)
db.flush()
first = domain_api.create_user(
UserCreate(
email="duplicate@schubamed.de",
first_name="First",
last_name="User",
role=UserRole.MITARBEITER,
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
),
session=db,
_=admin,
)
assert first.status_code == 201
with pytest.raises(HTTPException) as exc_info:
domain_api.create_user(
UserCreate(
email="duplicate@schubamed.de",
first_name="Second",
last_name="User",
role=UserRole.PRUEFER,
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
),
session=db,
_=admin,
)
assert exc_info.value.status_code == 409
follow_up = domain_api.create_user(
UserCreate(
email="fresh@schubamed.de",
first_name="Fresh",
last_name="User",
role=UserRole.LESER,
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
),
session=db,
_=admin,
)
assert follow_up.status_code == 201
def test_update_user_keeps_same_email_without_false_duplicate():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
target = User(
email="target@schubamed.de",
first_name="Target",
last_name="User",
role=UserRole.MITARBEITER.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add_all([admin, target])
db.flush()
response = domain_api.update_user(
target.id,
UserUpdate(
first_name="Target",
last_name="User",
email="target@schubamed.de",
role=UserRole.MITARBEITER,
is_active=True,
must_change_password=False,
),
session=db,
me=admin,
)
assert response.email == "target@schubamed.de"
assert response.id == target.id
def test_delete_user_without_references_removes_row():
db = session()
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
user = User(
email="delete-me@schubamed.de",
first_name="Delete",
last_name="Me",
role=UserRole.MITARBEITER.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
db.add_all([admin, user])
db.flush()
response = domain_api.delete_user(user.id, session=db, me=admin)
assert response.status_code == 204
assert db.get(User, user.id) is None
def test_delete_user_with_validation_reference_soft_deletes_row():
db = session()
customer, location, device = seed(db)
admin = User(
email="admin@schubamed.de",
first_name="Admin",
last_name="User",
role=UserRole.ADMIN.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
examiner = User(
email="examiner@schubamed.de",
first_name="Examiner",
last_name="User",
role=UserRole.PRUEFER.value,
password_hash="hash",
is_active=True,
must_change_password=False,
)
validation = valid_validation(customer, location, device)
db.add_all([admin, examiner, validation])
db.flush()
validation.examiner_id = examiner.id
db.flush()
response = domain_api.delete_user(examiner.id, session=db, me=admin)
assert response.status_code == 204
db.refresh(examiner)
assert examiner.deleted_at is not None
assert examiner.is_active is False
listed = domain_api.list_users(session=db, _=admin, page=1, page_size=20, search=None, role=None, active=None, sort_by="created_at", sort_order="desc")
assert all(item.id != examiner.id for item in listed["items"])
def test_invalid_user_role_is_rejected_by_schema():
with pytest.raises(Exception):
UserCreate(
email="bad-role@schubamed.de",
first_name="Bad",
last_name="Role",
role="invalid-role", # type: ignore[arg-type]
is_active=True,
must_change_password=False,
temporary_password="TempPassword123!",
)
def test_login_updates_last_login_at(): def test_login_updates_last_login_at():
from app.core.security import hash_password from app.core.security import hash_password

View file

@ -24,7 +24,7 @@ type DashboardData = {
}; };
const primaryCards = [ const primaryCards = [
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus }, { label: "Neue Validierung starten", value: "Schnellstart", href: "/validations/quick-start", icon: Plus },
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList }, { label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
{ label: "Kunden", key: "customers", href: "/customers", icon: Users }, { label: "Kunden", key: "customers", href: "/customers", icon: Users },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope }, { label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },

View file

@ -2,12 +2,13 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react"; import { Copy, RotateCcw, Search, Trash2, UserPlus2, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useAuth } from "@/components/auth"; import { useAuth } from "@/components/auth";
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api"; import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
import { buildUsersQueryParams } from "@/lib/users-query";
const schema = z.object({ const schema = z.object({
first_name: z.string().min(1, "Vorname erforderlich"), first_name: z.string().min(1, "Vorname erforderlich"),
@ -33,12 +34,14 @@ export default function UsersPage() {
const client = useQueryClient(); const client = useQueryClient();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [role, setRole] = useState(""); const [role, setRole] = useState("");
const [active, setActive] = useState(""); const [active, setActive] = useState<"all" | "active" | "inactive">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState("created_at"); const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [editing, setEditing] = useState<User | null>(null); const [editing, setEditing] = useState<User | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState(""); const [temporaryPassword, setTemporaryPassword] = useState("");
const [temporaryPasswordOpen, setTemporaryPasswordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -55,26 +58,30 @@ export default function UsersPage() {
} }
}); });
const queryKey = useMemo(
() => ["users", token, page, search, role, active, sortBy, sortOrder],
[token, page, search, role, active, sortBy, sortOrder]
);
const users = useQuery({ const users = useQuery({
queryKey, queryKey: ["users", page, 20, search, role, active, sortBy, sortOrder],
queryFn: () => queryFn: () =>
apiGet<Paginated<User>>( apiGet<Paginated<User>>(
`/users?page=${page}&page_size=10&search=${encodeURIComponent(search)}&role=${encodeURIComponent(role)}&active=${encodeURIComponent(active)}&sort_by=${encodeURIComponent(sortBy)}&sort_order=${sortOrder}`, `/users?${buildUsersQueryParams({
page,
pageSize: 20,
search,
role,
active: active === "all" ? undefined : active === "active",
sortBy,
sortOrder
}).toString()}`,
token ?? "" token ?? ""
), ),
enabled: Boolean(token) enabled: Boolean(token)
}); });
const pages = users.data?.pages ?? Math.max(1, Math.ceil((users.data?.total ?? 0) / (users.data?.page_size ?? 20)));
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (values: UserForm) => { mutationFn: async (values: UserForm) => {
const payload = { const payload = {
...values, ...values,
email: values.email.toLowerCase() email: values.email.trim().toLowerCase()
}; };
if (editing) { if (editing) {
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) }); return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
@ -85,31 +92,38 @@ export default function UsersPage() {
}); });
}, },
onSuccess: async (created) => { onSuccess: async (created) => {
const isCreate = !editing;
if (!editing) { if (!editing) {
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? ""); setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
} else {
setTemporaryPassword("");
} }
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt"); setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
setError(""); setError("");
setEditing(null); setEditing(null);
setTemporaryPasswordOpen(isCreate && Boolean((created as { temporary_password?: string }).temporary_password));
form.reset(); form.reset();
await client.invalidateQueries({ queryKey: ["users"] }); await client.invalidateQueries({ queryKey: ["users"] });
}, },
onError: (err: Error) => setError(err.message) onError: (err: Error) => setError(err.message)
}); });
const deactivateMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }), mutationFn: (item: User) => apiDelete(`/users/${item.id}`, token ?? ""),
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) onSuccess: async () => {
}); setMessage("Benutzer geloescht");
const activateMutation = useMutation({ setError("");
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/activate`, token ?? "", { method: "POST" }), setDeleteTarget(null);
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] }) await client.invalidateQueries({ queryKey: ["users"] });
},
onError: (err: Error) => setError(err.message)
}); });
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }), mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
onSuccess: (result) => { onSuccess: (result) => {
setTemporaryPassword(result.temporary_password); setTemporaryPassword(result.temporary_password);
setMessage("Temporäres Passwort erzeugt"); setMessage("Temporäres Passwort erzeugt");
setTemporaryPasswordOpen(true);
} }
}); });
@ -135,6 +149,11 @@ export default function UsersPage() {
}); });
} }
function submit(values: UserForm) {
if (saveMutation.isPending) return;
saveMutation.mutate(values);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@ -154,10 +173,10 @@ export default function UsersPage() {
<option value="">Alle Rollen</option> <option value="">Alle Rollen</option>
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)} {Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select> </select>
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3"> <select value={active} onChange={(e) => setActive(e.target.value as "all" | "active" | "inactive")} className="h-12 rounded-lg border border-border px-3">
<option value="">Alle Status</option> <option value="all">Alle Status</option>
<option value="true">Aktiv</option> <option value="active">Aktiv</option>
<option value="false">Inaktiv</option> <option value="inactive">Inaktiv</option>
</select> </select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3"> <select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
<option value="created_at">Erstellt am</option> <option value="created_at">Erstellt am</option>
@ -173,6 +192,14 @@ export default function UsersPage() {
</div> </div>
</section> </section>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft"> <section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
{users.isLoading ? (
<div className="flex items-center gap-3 px-5 py-6 text-sm text-text-light">
<span className="spinner" />
Benutzer werden geladen...
</div>
) : users.isError ? (
<div className="px-5 py-6 text-sm text-danger">Benutzer konnten nicht geladen werden.</div>
) : null}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light"> <thead className="bg-background text-xs uppercase text-text-light">
@ -187,7 +214,14 @@ export default function UsersPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{(users.data?.items ?? []).map((item) => ( {!users.isLoading && !users.isError && (users.data?.items.length ?? 0) === 0 ? (
<tr>
<td className="px-5 py-6 text-sm text-text-light" colSpan={7}>
Keine Benutzer vorhanden.
</td>
</tr>
) : (
(users.data?.items ?? []).map((item) => (
<tr key={item.id}> <tr key={item.id}>
<td className="px-5 py-4">{item.first_name} {item.last_name}</td> <td className="px-5 py-4">{item.first_name} {item.last_name}</td>
<td className="px-5 py-4">{item.email}</td> <td className="px-5 py-4">{item.email}</td>
@ -198,16 +232,13 @@ export default function UsersPage() {
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button> <button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Temporäres Passwort setzen</button> <button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Passwort zurücksetzen</button>
{item.is_active ? ( <button type="button" className="btn btn-danger h-10 px-3" onClick={() => setDeleteTarget(item)}><Trash2 className="h-4 w-4" /> Löschen</button>
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => deactivateMutation.mutate(item)}>Deaktivieren</button>
) : (
<button type="button" className="btn btn-success h-10 px-3" onClick={() => activateMutation.mutate(item)}><Check className="h-4 w-4" /> Reaktivieren</button>
)}
</div> </div>
</td> </td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -216,14 +247,29 @@ export default function UsersPage() {
<span>{users.data?.total ?? 0} Datensätze</span> <span>{users.data?.total ?? 0} Datensätze</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button> <button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
<span>Seite {page}</span> <span>Seite {page} von {pages}</span>
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button> <button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 20 || page >= pages} onClick={() => setPage((v) => v + 1)}>Weiter</button>
</div> </div>
</footer> </footer>
{temporaryPassword && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 shadow-soft"><p className="text-sm text-text-light">Temporäres Passwort</p><p className="mt-1 font-mono text-sm">{temporaryPassword}</p></div>} {temporaryPasswordOpen && temporaryPassword && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<div className="w-full max-w-md rounded-lg border border-border bg-surface p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Passwort zurückgesetzt</h2>
<button type="button" aria-label="Schliessen" onClick={() => setTemporaryPasswordOpen(false)} className="icon-btn"><X className="h-4 w-4" /></button>
</div>
<p className="mt-3 text-sm text-text-light">Dieses Passwort wird nur einmal angezeigt.</p>
<div className="mt-4 rounded-lg border border-border bg-background px-4 py-3 font-mono text-lg tracking-wide">{temporaryPassword}</div>
<div className="mt-4 flex flex-wrap gap-3">
<button type="button" className="btn btn-secondary h-11" onClick={async () => navigator.clipboard.writeText(temporaryPassword)}><Copy className="h-4 w-4" /> Kopieren</button>
<button type="button" className="btn btn-primary h-11" onClick={() => setTemporaryPasswordOpen(false)}>Schliessen</button>
</div>
</div>
</div>
)}
{message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>} {message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>}
{error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>} {error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>}
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft"> <form onSubmit={form.handleSubmit(submit)} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2> <h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => ( {(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
@ -243,9 +289,28 @@ export default function UsersPage() {
</div> </div>
<div className="mt-5 flex justify-end gap-3"> <div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button> <button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
<button type="submit" className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null} Speichern</button> <button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null}{saveMutation.isPending ? "Speichern..." : "Speichern"}</button>
</div> </div>
</form> </form>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<div className="w-full max-w-lg rounded-lg border border-border bg-surface p-5 shadow-soft">
<h2 className="text-xl font-semibold">Benutzer wirklich löschen?</h2>
<p className="mt-2 text-sm text-text-light">
{deleteTarget.first_name} {deleteTarget.last_name}
<br />
{deleteTarget.email}
</p>
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="btn btn-secondary" onClick={() => setDeleteTarget(null)}>Abbrechen</button>
<button type="button" className="btn btn-danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(deleteTarget)}>
{deleteMutation.isPending ? <span className="spinner" /> : null}
{deleteMutation.isPending ? "Löschen..." : "Löschen"}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
import ValidationEditor from "@/components/validations/validation-editor";
export default function QuickStartValidationPage() {
return (
<div className="space-y-6">
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<p className="text-sm font-medium uppercase tracking-wide text-primary">Schnellstart</p>
<h1 className="mt-2 text-3xl font-semibold text-text">Neue Validierung starten</h1>
<p className="mt-2 max-w-3xl text-text-light">
Wir arbeiten die Validierung direkt auf einer Seite durch und ziehen vorhandene Daten sofort nach.
</p>
<div className="mt-5">
<Link className="btn btn-secondary" href="/validations">
Zur Validierungsübersicht
</Link>
</div>
</header>
<ValidationEditor />
</div>
);
}

View file

@ -118,8 +118,22 @@ export type Paginated<T> = {
total: number; total: number;
page: number; page: number;
page_size: number; page_size: number;
pages?: number;
}; };
async function readErrorMessage(response: Response, fallback: string) {
const text = await response.text();
if (!text) {
return fallback;
}
try {
const parsed = JSON.parse(text) as { detail?: string; message?: string };
return parsed.detail ?? parsed.message ?? text;
} catch {
return text;
}
}
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
const response = await fetch(`/api/login`, { const response = await fetch(`/api/login`, {
method: "POST", method: "POST",
@ -155,8 +169,7 @@ export async function apiSend<T>(path: string, token: string, method: "POST" | "
credentials: "include" credentials: "include"
}); });
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
throw new Error(detail || `API request failed: ${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@ -167,7 +180,7 @@ export async function apiDelete(path: string, token: string): Promise<void> {
credentials: "include" credentials: "include"
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed: ${response.status}`); throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
} }
} }
@ -178,7 +191,7 @@ export async function apiFetch<T>(path: string, token: string, init?: RequestIni
credentials: "include" credentials: "include"
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(await response.text()); throw new Error(await readErrorMessage(response, `API request failed: ${response.status}`));
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }

View file

@ -0,0 +1,9 @@
export function buildUsersQueryParams(input: {
page: number;
pageSize: number;
search?: string;
role?: string;
active?: boolean | null;
sortBy: string;
sortOrder: "asc" | "desc";
}): URLSearchParams;

View file

@ -0,0 +1,18 @@
export function buildUsersQueryParams({ page, pageSize, search, role, active, sortBy, sortOrder }) {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
sort_order: sortOrder
});
if (search && search.trim()) {
params.set("search", search.trim());
}
if (role) {
params.set("role", role);
}
if (active === true || active === false) {
params.set("active", String(active));
}
return params;
}

View file

@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test:auth": "node --test tests/auth-cookie.test.mjs" "test:auth": "node --test tests/auth-cookie.test.mjs",
"test:users-query": "node --test tests/users-query.test.mjs"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",

View file

@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildUsersQueryParams } from "../lib/users-query.js";
test("builds query without empty optional filters", () => {
const params = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: "",
role: "",
active: undefined,
sortBy: "created_at",
sortOrder: "desc"
});
assert.equal(params.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc");
});
test("builds query with active filters", () => {
const activeParams = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: " Admin ",
role: "admin",
active: true,
sortBy: "created_at",
sortOrder: "desc"
});
const inactiveParams = buildUsersQueryParams({
page: 1,
pageSize: 20,
search: "",
role: "",
active: false,
sortBy: "created_at",
sortOrder: "desc"
});
assert.equal(activeParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&search=Admin&role=admin&active=true");
assert.equal(inactiveParams.toString(), "page=1&page_size=20&sort_by=created_at&sort_order=desc&active=false");
});