feat(5.2): add User Preferences API with full-stack implementation

Backend:
- Create app/models/user_preference.py with TenantMixin (user_id, key, value JSONB)
- Create app/routes/user_preferences.py with GET/PUT/DELETE endpoints + RBAC
- Add user_preferences:read/write to CORE_PERMISSIONS
- Add user_preferences to legacy role permissions (admin/editor/viewer)
- Register route in app/main.py and app/routes/__init__.py
- Create alembic migration 0028_user_preferences
- Add UserPreference model to conftest.py for test schema
- Fix pre-existing conftest seed (Contact industry field removed in migration 0027)

Frontend:
- Create frontend/src/api/userPreferences.ts with React Query hooks
- Create frontend/src/hooks/useUserPreferences.ts syncing with uiStore
- Add i18n entries for de.json and en.json

Tests:
- 13 tests covering CRUD, tenant isolation, CSRF, unauthenticated access
- All tests passing
This commit is contained in:
Agent Zero
2026-07-23 20:39:42 +02:00
parent a9151b1159
commit 75a7063bff
13 changed files with 762 additions and 4 deletions
+46
View File
@@ -0,0 +1,46 @@
"""Create user_preferences table for per-user UI settings.
Revision ID: 0028
Revises: 0027_unify_company_to_contact
Create Date: 2026-07-23
Changes:
- Create user_preferences table with tenant_id, user_id, key, value (JSONB)
- Unique constraint on (tenant_id, user_id, key)
- Indexes on tenant_id+user_id and user_id
- tenant_id column (required by TenantMixin / RLS)
- created_at, updated_at, deleted_at columns (TimestampMixin + SoftDeleteMixin)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
revision = "0028_user_preferences"
down_revision = "0027_unify_company_to_contact"
def upgrade():
op.create_table(
"user_preferences",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("key", sa.String(100), nullable=False),
sa.Column("value", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("tenant_id", "user_id", "key", name="uq_user_prefs_tenant_user_key"),
)
op.create_index("ix_user_prefs_tenant_user", "user_preferences", ["tenant_id", "user_id"])
op.create_index("ix_user_prefs_user_id", "user_preferences", ["user_id"])
op.create_index("ix_user_prefs_tenant_id", "user_preferences", ["tenant_id"])
def downgrade():
op.drop_index("ix_user_prefs_tenant_id", table_name="user_preferences")
op.drop_index("ix_user_prefs_user_id", table_name="user_preferences")
op.drop_index("ix_user_prefs_tenant_user", table_name="user_preferences")
op.drop_table("user_preferences")
+2
View File
@@ -41,6 +41,8 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "attachments:delete", "label": "Attachments: Delete", "category": "core", "module": "attachments"},
{"key": "workflows:read", "label": "Workflows: Read", "category": "core", "module": "workflows"},
{"key": "workflows:write", "label": "Workflows: Write", "category": "core", "module": "workflows"},
{"key": "user_preferences:read", "label": "User Preferences: Read", "category": "core", "module": "user_preferences"},
{"key": "user_preferences:write", "label": "User Preferences: Write", "category": "core", "module": "user_preferences"},
{"key": "sequences:read", "label": "Sequences: Read", "category": "core", "module": "sequences"},
{"key": "sequences:write", "label": "Sequences: Write", "category": "core", "module": "sequences"},
{"key": "addresses:read", "label": "Addresses: Read", "category": "core", "module": "addresses"},
+4 -2
View File
@@ -162,12 +162,14 @@ async def resolve_permissions(
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
"taxes:read", "taxes:write", "currencies:read", "currencies:write",
"notifications:read", "notifications:write", "import_export:read",
"import_export:write"}
"import_export:write",
"user_preferences:read", "user_preferences:write"}
elif legacy_role == "viewer":
allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read",
"audit:read", "attachments:read", "workflows:read", "sequences:read",
"addresses:read", "taxes:read", "currencies:read",
"notifications:read", "import_export:read"}
"notifications:read", "import_export:read",
"user_preferences:read", "user_preferences:write"}
# Load group permissions
ug_q = select(UserGroup).where(
+2
View File
@@ -40,6 +40,7 @@ from app.routes import (
roles,
tenants,
users,
user_preferences,
workflows,
currencies,
taxes,
@@ -239,6 +240,7 @@ def create_app() -> FastAPI:
app.include_router(plugins.router)
app.include_router(ai_copilot.router)
app.include_router(workflows.router)
app.include_router(user_preferences.router)
app.include_router(currencies.router)
app.include_router(taxes.router)
app.include_router(sequences.router)
+48
View File
@@ -0,0 +1,48 @@
"""User Preference model — per-user UI settings stored as key/value JSONB, tenant-scoped."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class UserPreference(Base, TenantMixin):
"""Per-user preference entry — stores a single UI preference as JSONB value.
Keys are arbitrary strings (e.g. 'sidebar_collapsed', 'theme', 'active_tab').
Values are JSONB to support complex preference structures.
"""
__tablename__ = "user_preferences"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"user_id",
"key",
name="uq_user_prefs_tenant_user_key",
),
Index(
"ix_user_prefs_tenant_user",
"tenant_id",
"user_id",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict)
+1
View File
@@ -20,5 +20,6 @@ from app.routes import (
roles, # noqa: F401
tenants, # noqa: F401
users, # noqa: F401
user_preferences, # noqa: F401
workflows, # noqa: F401
)
+195
View File
@@ -0,0 +1,195 @@
"""User Preferences routes — per-user UI settings via API, tenant-scoped with RBAC."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.models.user_preference import UserPreference
router = APIRouter(prefix="/api/v1/user/preferences", tags=["user-preferences"])
# ─── Schemas ───
class PreferenceValue(BaseModel):
"""Arbitrary JSON value for a preference key."""
value: Any = Field(..., description="JSON value for the preference key")
class PreferenceResponse(BaseModel):
"""Single preference entry response."""
key: str
value: Any
updated_at: str | None = None
class PreferenceListResponse(BaseModel):
"""All preferences for the current user."""
preferences: list[PreferenceResponse]
# ─── Helpers ───
def _pref_to_response(p: UserPreference) -> PreferenceResponse:
"""Convert UserPreference model to response schema."""
updated_at = None
try:
updated_at = p.updated_at.isoformat() if p.updated_at else None
except Exception:
pass
return PreferenceResponse(
key=p.key,
value=p.value,
updated_at=updated_at,
)
# ─── Endpoints ───
@router.get("")
async def list_user_preferences(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(
require_permission("user_preferences:read")
),
) -> PreferenceListResponse:
"""Get all preferences for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(UserPreference)
.where(
UserPreference.tenant_id == tenant_id,
UserPreference.user_id == user_id,
UserPreference.deleted_at.is_(None),
)
.order_by(UserPreference.key)
)
prefs = result.scalars().all()
return PreferenceListResponse(
preferences=[_pref_to_response(p) for p in prefs]
)
@router.get("/{key}")
async def get_user_preference(
key: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(
require_permission("user_preferences:read")
),
) -> PreferenceResponse:
"""Get a single preference by key."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(UserPreference).where(
UserPreference.tenant_id == tenant_id,
UserPreference.user_id == user_id,
UserPreference.key == key,
UserPreference.deleted_at.is_(None),
)
)
pref = result.scalar_one_or_none()
if pref is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"detail": f"Preference '{key}' not found",
"code": "not_found",
},
)
return _pref_to_response(pref)
@router.put("/{key}")
async def upsert_user_preference(
key: str,
body: PreferenceValue,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(
require_permission("user_preferences:write")
),
) -> PreferenceResponse:
"""Create or update a preference by key (upsert)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(UserPreference).where(
UserPreference.tenant_id == tenant_id,
UserPreference.user_id == user_id,
UserPreference.key == key,
UserPreference.deleted_at.is_(None),
)
)
pref = result.scalar_one_or_none()
if pref is None:
pref = UserPreference(
tenant_id=tenant_id,
user_id=user_id,
key=key,
value=body.value,
)
db.add(pref)
else:
pref.value = body.value
await db.commit()
await db.refresh(pref)
return _pref_to_response(pref)
@router.delete("/{key}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_preference(
key: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(
require_permission("user_preferences:write")
),
):
"""Delete a preference by key (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(UserPreference).where(
UserPreference.tenant_id == tenant_id,
UserPreference.user_id == user_id,
UserPreference.key == key,
UserPreference.deleted_at.is_(None),
)
)
pref = result.scalar_one_or_none()
if pref is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"detail": f"Preference '{key}' not found",
"code": "not_found",
},
)
# Soft-delete via TenantMixin's deleted_at
from datetime import UTC, datetime
pref.deleted_at = datetime.now(UTC)
await db.commit()
return None
+74
View File
@@ -0,0 +1,74 @@
/**
* User Preferences API — per-user UI settings stored server-side.
* Matches backend endpoints from /api/v1/user/preferences.
*
* Backend: app/routes/user_preferences.py, app/models/user_preference.py
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPut, apiDelete } from './client';
// ── Types ──
export interface PreferenceEntry {
key: string;
value: unknown;
updated_at?: string | null;
}
export interface PreferenceListResponse {
preferences: PreferenceEntry[];
}
// ── Hooks ──
export function useUserPreferences() {
return useQuery({
queryKey: ['userPreferences'],
queryFn: () =>
apiGet<PreferenceListResponse>('/user/preferences'),
});
}
export function useUserPreference(key: string) {
return useQuery({
queryKey: ['userPreferences', key],
queryFn: () =>
apiGet<PreferenceEntry>(`/user/preferences/${key}`),
enabled: !!key,
});
}
export function useUpsertUserPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
key,
value,
}: {
key: string;
value: unknown;
}) =>
apiPut<PreferenceEntry>(`/user/preferences/${key}`, {
value,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['userPreferences'],
});
},
});
}
export function useDeleteUserPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: string) =>
apiDelete(`/user/preferences/${key}`),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['userPreferences'],
});
},
});
}
+125
View File
@@ -0,0 +1,125 @@
/**
* useUserPreferences — loads user preferences from API and syncs with uiStore.
*
* On load: applies server-side preferences to the Zustand uiStore (theme, locale,
* sidebar state, AI sidebar tab, etc.).
* On change: persists uiStore state to the server via upsert mutation.
*/
import { useEffect, useCallback } from 'react';
import { useUIStore, type Theme, type Locale, type AISidebarTab } from '@/store/uiStore';
import {
useUserPreferences,
useUpsertUserPreference,
useDeleteUserPreference,
} from '@/api/userPreferences';
// Known preference keys that map to uiStore fields
const PREF_KEYS = {
THEME: 'theme',
LOCALE: 'locale',
SIDEBAR_OPEN: 'sidebar_open',
AI_SIDEBAR_COLLAPSED: 'ai_sidebar_collapsed',
AI_SIDEBAR_TAB: 'ai_sidebar_tab',
MESSAGE_SIDEBAR_COLLAPSED: 'message_sidebar_collapsed',
SUGGESTION_SIDEBAR_OPEN: 'suggestion_sidebar_open',
} as const;
export function useUserPreferencesSync() {
const { data, isLoading } = useUserPreferences();
const upsertMutation = useUpsertUserPreference();
const deleteMutation = useDeleteUserPreference();
const {
theme,
locale,
sidebarOpen,
aiSidebarCollapsed,
aiSidebarTab,
messageSidebarCollapsed,
suggestionSidebarOpen: _suggestionSidebarOpen,
setTheme,
setLocale,
setSidebarOpen,
setAISidebarCollapsed,
setAISidebarTab,
setMessageSidebarCollapsed,
} = useUIStore();
// Apply server preferences to uiStore on initial load
useEffect(() => {
if (!data?.preferences) return;
const prefMap = new Map<string, unknown>();
for (const pref of data.preferences) {
prefMap.set(pref.key, pref.value);
}
// Only apply if the server has a value — don't override local defaults with null
if (prefMap.has(PREF_KEYS.THEME)) {
const serverTheme = prefMap.get(PREF_KEYS.THEME) as Theme;
if (serverTheme && serverTheme !== theme) {
setTheme(serverTheme);
}
}
if (prefMap.has(PREF_KEYS.LOCALE)) {
const serverLocale = prefMap.get(PREF_KEYS.LOCALE) as Locale;
if (serverLocale && serverLocale !== locale) {
setLocale(serverLocale);
}
}
if (prefMap.has(PREF_KEYS.SIDEBAR_OPEN)) {
const serverSidebar = prefMap.get(PREF_KEYS.SIDEBAR_OPEN) as boolean;
if (serverSidebar !== sidebarOpen) {
setSidebarOpen(serverSidebar);
}
}
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_COLLAPSED)) {
const serverCollapsed = prefMap.get(
PREF_KEYS.AI_SIDEBAR_COLLAPSED
) as boolean;
if (serverCollapsed !== aiSidebarCollapsed) {
setAISidebarCollapsed(serverCollapsed);
}
}
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_TAB)) {
const serverTab = prefMap.get(PREF_KEYS.AI_SIDEBAR_TAB) as AISidebarTab;
if (serverTab && serverTab !== aiSidebarTab) {
setAISidebarTab(serverTab);
}
}
if (prefMap.has(PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED)) {
const serverCollapsed = prefMap.get(
PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED
) as boolean;
if (serverCollapsed !== messageSidebarCollapsed) {
setMessageSidebarCollapsed(serverCollapsed);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Persist a single preference to the server
const savePreference = useCallback(
(key: string, value: unknown) => {
upsertMutation.mutate({ key, value });
},
[upsertMutation]
);
// Delete a preference from the server
const removePreference = useCallback(
(key: string) => {
deleteMutation.mutate(key);
},
[deleteMutation]
);
return {
isLoading,
savePreference,
removePreference,
isSaving: upsertMutation.isPending,
isDeleting: deleteMutation.isPending,
};
}
+14
View File
@@ -893,6 +893,20 @@
"changes": "Änderungen",
"fieldsChanged": "Felder geändert"
},
"userPreferences": {
"title": "Benutzereinstellungen",
"saved": "Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"deleted": "Einstellung gelöscht",
"deleteError": "Fehler beim Löschen der Einstellung",
"loading": "Einstellungen werden geladen",
"theme": "Design",
"language": "Sprache",
"sidebar": "Seitenleiste",
"activeTab": "Aktiver Tab",
"sortOrder": "Sortierreihenfolge",
"synced": "Einstellungen synchronisiert"
},
"ai": {
"uiControl": {
"active": "KI steuert die UI",
+14
View File
@@ -893,6 +893,20 @@
"changes": "Changes",
"fieldsChanged": "fields changed"
},
"userPreferences": {
"title": "User Preferences",
"saved": "Preferences saved",
"saveError": "Failed to save preferences",
"deleted": "Preference deleted",
"deleteError": "Failed to delete preference",
"loading": "Loading preferences",
"theme": "Theme",
"language": "Language",
"sidebar": "Sidebar",
"activeTab": "Active Tab",
"sortOrder": "Sort Order",
"synced": "Preferences synced"
},
"ai": {
"uiControl": {
"active": "AI is controlling the UI",
+5 -2
View File
@@ -35,6 +35,7 @@ from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.user_preference import UserPreference # noqa: F401
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.plugins.builtins.calendar.models import ( # noqa: F401
@@ -270,16 +271,18 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
# Create a company in tenant A
company_a = Contact(
tenant_id=tenant_a.id,
type="company",
name="Company Alpha",
industry="IT",
displayname="Company Alpha",
created_by=admin_a.id,
updated_by=admin_a.id,
)
# Create a company in tenant B
company_b = Contact(
tenant_id=tenant_b.id,
type="company",
name="Company Beta",
industry="Finance",
displayname="Company Beta",
created_by=admin_b.id,
updated_by=admin_b.id,
)
+232
View File
@@ -0,0 +1,232 @@
"""Tests for User Preferences API — CRUD, tenant isolation, RBAC enforcement."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
async def _login_with_csrf(client: AsyncClient, email: str) -> str:
"""Login and return the CSRF token for subsequent unsafe requests."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
return resp.json()["csrf_token"]
def _csrf_headers(csrf_token: str) -> dict:
"""Return headers dict with Origin + X-CSRF-Token for unsafe methods."""
return {**ORIGIN_HEADER, "X-CSRF-Token": csrf_token}
@pytest.mark.asyncio
class TestUserPreferencesList:
"""GET /api/v1/user/preferences — list all preferences for current user."""
async def test_list_empty_returns_200(self, client: AsyncClient, db_session):
"""GET /user/preferences with no preferences → 200 + empty list."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert "preferences" in data
assert data["preferences"] == []
async def test_list_returns_saved_preferences(self, client: AsyncClient, db_session):
"""GET /user/preferences after PUT → 200 + saved entries."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
# Save a preference first
resp = await client.put(
"/api/v1/user/preferences/theme",
json={"value": "dark"},
headers=_csrf_headers(csrf),
)
assert resp.status_code == 200
resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert len(data["preferences"]) >= 1
keys = [p["key"] for p in data["preferences"]]
assert "theme" in keys
async def test_list_unauthenticated_returns_401(self, client: AsyncClient):
"""GET /user/preferences without auth → 401."""
resp = await client.get("/api/v1/user/preferences")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestUserPreferencesGet:
"""GET /api/v1/user/preferences/{key} — get single preference."""
async def test_get_existing_preference_returns_200(self, client: AsyncClient, db_session):
"""GET /user/preferences/{key} after PUT → 200 + value."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
await client.put(
"/api/v1/user/preferences/sidebar_open",
json={"value": False},
headers=_csrf_headers(csrf),
)
resp = await client.get(
"/api/v1/user/preferences/sidebar_open", headers=ORIGIN_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["key"] == "sidebar_open"
assert data["value"] is False
async def test_get_nonexistent_returns_404(self, client: AsyncClient, db_session):
"""GET /user/preferences/{key} for missing key → 404."""
await seed_tenant_and_users(db_session)
await _login_with_csrf(client, "admin@tenanta.com")
resp = await client.get(
"/api/v1/user/preferences/nonexistent", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestUserPreferencesUpsert:
"""PUT /api/v1/user/preferences/{key} — create or update preference."""
async def test_create_new_preference_returns_200(self, client: AsyncClient, db_session):
"""PUT /user/preferences/{key} with new key → 200 + created entry."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
resp = await client.put(
"/api/v1/user/preferences/theme",
json={"value": "dark"},
headers=_csrf_headers(csrf),
)
assert resp.status_code == 200
data = resp.json()
assert data["key"] == "theme"
assert data["value"] == "dark"
assert "updated_at" in data
async def test_update_existing_preference_returns_200(self, client: AsyncClient, db_session):
"""PUT /user/preferences/{key} with existing key → 200 + updated value."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
# Create
await client.put(
"/api/v1/user/preferences/locale",
json={"value": "de"},
headers=_csrf_headers(csrf),
)
# Update
resp = await client.put(
"/api/v1/user/preferences/locale",
json={"value": "en"},
headers=_csrf_headers(csrf),
)
assert resp.status_code == 200
data = resp.json()
assert data["key"] == "locale"
assert data["value"] == "en"
async def test_upsert_complex_json_value(self, client: AsyncClient, db_session):
"""PUT /user/preferences/{key} with complex JSON value → 200."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
complex_value = {
"sort_by": "name",
"sort_order": "asc",
"filters": {"industry": "IT", "status": "active"},
}
resp = await client.put(
"/api/v1/user/preferences/contact_list_settings",
json={"value": complex_value},
headers=_csrf_headers(csrf),
)
assert resp.status_code == 200
data = resp.json()
assert data["value"]["sort_by"] == "name"
assert data["value"]["filters"]["industry"] == "IT"
async def test_upsert_unauthenticated_returns_401(self, client: AsyncClient):
"""PUT /user/preferences/{key} without auth → 401 or 403 (CSRF block)."""
resp = await client.put(
"/api/v1/user/preferences/theme",
json={"value": "dark"},
headers=ORIGIN_HEADER,
)
# Without session, CSRF middleware blocks with 403 before auth check
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
class TestUserPreferencesDelete:
"""DELETE /api/v1/user/preferences/{key} — remove a preference."""
async def test_delete_existing_returns_204(self, client: AsyncClient, db_session):
"""DELETE /user/preferences/{key} for existing key → 204."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
# Create first
await client.put(
"/api/v1/user/preferences/theme",
json={"value": "dark"},
headers=_csrf_headers(csrf),
)
resp = await client.delete(
"/api/v1/user/preferences/theme", headers=_csrf_headers(csrf)
)
assert resp.status_code == 204
async def test_delete_nonexistent_returns_404(self, client: AsyncClient, db_session):
"""DELETE /user/preferences/{key} for missing key → 404."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
resp = await client.delete(
"/api/v1/user/preferences/nonexistent", headers=_csrf_headers(csrf)
)
assert resp.status_code == 404
async def test_delete_then_get_returns_404(self, client: AsyncClient, db_session):
"""After DELETE, GET /user/preferences/{key} → 404."""
await seed_tenant_and_users(db_session)
csrf = await _login_with_csrf(client, "admin@tenanta.com")
await client.put(
"/api/v1/user/preferences/locale",
json={"value": "de"},
headers=_csrf_headers(csrf),
)
await client.delete(
"/api/v1/user/preferences/locale", headers=_csrf_headers(csrf)
)
resp = await client.get(
"/api/v1/user/preferences/locale", headers=ORIGIN_HEADER
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestUserPreferencesTenantIsolation:
"""Preferences are tenant-scoped — users in different tenants can't see each other."""
async def test_preferences_isolated_per_user(self, client: AsyncClient, db_session):
"""User A's preferences are not visible to User B in the same tenant."""
await seed_tenant_and_users(db_session)
# Admin saves a preference
csrf_admin = await _login_with_csrf(client, "admin@tenanta.com")
await client.put(
"/api/v1/user/preferences/theme",
json={"value": "dark"},
headers=_csrf_headers(csrf_admin),
)
# Viewer logs in — should not see admin's preferences
csrf_viewer = await _login_with_csrf(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
keys = [p["key"] for p in data["preferences"]]
assert "theme" not in keys