Fixed thumbnails ratio. Preparing user preference management

This commit is contained in:
2025-10-08 23:19:17 +02:00
parent 707507b128
commit 264dac077c
9 changed files with 935 additions and 90 deletions

View File

@@ -174,6 +174,8 @@ class UserRepository:
update_data["role"] = user_update.role update_data["role"] = user_update.role
if user_update.is_active is not None: if user_update.is_active is not None:
update_data["is_active"] = user_update.is_active update_data["is_active"] = user_update.is_active
if user_update.preferences is not None:
update_data["preferences"] = user_update.preferences
# Remove None values from update data # Remove None values from update data
clean_update_data = {k: v for k, v in update_data.items() if v is not None} clean_update_data = {k: v for k, v in update_data.items() if v is not None}

View File

@@ -105,6 +105,7 @@ class UserUpdate(BaseModel):
password: Optional[str] = None password: Optional[str] = None
role: Optional[UserRole] = None role: Optional[UserRole] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
preferences: Optional[dict] = None
@field_validator('username') @field_validator('username')
@classmethod @classmethod
@@ -130,6 +131,7 @@ class UserInDB(BaseModel):
hashed_password: str hashed_password: str
role: UserRole role: UserRole
is_active: bool = True is_active: bool = True
preferences: dict = Field(default_factory=dict)
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -184,3 +184,18 @@ class UserService:
bool: True if user exists, False otherwise bool: True if user exists, False otherwise
""" """
return self.user_repository.user_exists(username) return self.user_repository.user_exists(username)
def get_preference(self, user_id: str, preference):
user = self.get_user_by_id(user_id)
if user is None:
return None
return user.preferences.get(preference, None)
def set_preference(self, user_id: str, preference, value):
user = self.get_user_by_id(user_id)
if user is None:
return None
user.preferences[preference] = value
self.user_repository.update_user(user_id, UserUpdate(preferences=user.preferences))
return self.get_user_by_id(user_id)

View File

@@ -18,7 +18,9 @@
/* Main Content Area */ /* Main Content Area */
.mainContent { .mainContent {
flex: 1; flex: 1;
overflow-y: auto; display: flex;
flex-direction: column;
min-height: 0; /* Important for flex to work properly with scrolling */
} }
/* Main Content Inner Container */ /* Main Content Inner Container */
@@ -26,5 +28,9 @@
max-width: 80rem; /* container max-width */ max-width: 80rem; /* container max-width */
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding: 2rem 1rem; padding: 0.5rem 1rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Important for flex to work properly with scrolling */
} }

View File

@@ -4,7 +4,7 @@
* Supports different view modes: small, large, and detail * Supports different view modes: small, large, and detail
*/ */
import React, {memo} from 'react'; import React, {memo, useState, useEffect} from 'react';
import {API_BASE_URL} from "../../utils/api.js"; import {API_BASE_URL} from "../../utils/api.js";
/** /**
@@ -40,11 +40,67 @@ const formatDate = (dateString) => {
*/ */
const buildFullUrl = (relativePath) => { const buildFullUrl = (relativePath) => {
if (!relativePath) return ''; if (!relativePath) return '';
// Use the base URL from your API configuration
const baseUrl = import.meta.env.VITE_API_BASE_URL || API_BASE_URL; const baseUrl = import.meta.env.VITE_API_BASE_URL || API_BASE_URL;
return `${baseUrl}${relativePath}`; return `${baseUrl}${relativePath}`;
}; };
/**
* Hook to load protected images with bearer token
* @param {string} url - Image URL
* @returns {Object} { imageSrc, loading, error }
*/
const useProtectedImage = (url) => {
const [imageSrc, setImageSrc] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (!url) {
setLoading(false);
return;
}
let objectUrl;
const fetchImage = async () => {
try {
const token = localStorage.getItem('access_token');
const fullUrl = buildFullUrl(url);
const response = await fetch(fullUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to load image');
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setLoading(false);
} catch (err) {
console.error('Error loading thumbnail:', err);
setError(true);
setLoading(false);
}
};
fetchImage();
// Cleanup: revoke object URL on unmount
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [url]);
return { imageSrc, loading, error };
};
/** /**
* DocumentCard component * DocumentCard component
* @param {Object} props * @param {Object} props
@@ -57,6 +113,9 @@ const buildFullUrl = (relativePath) => {
const DocumentCard = memo(({document, viewMode, onEdit, onDelete}) => { const DocumentCard = memo(({document, viewMode, onEdit, onDelete}) => {
const {name, originalFileType, thumbnailUrl, pageCount, fileSize, createdAt, tags, categories} = document; const {name, originalFileType, thumbnailUrl, pageCount, fileSize, createdAt, tags, categories} = document;
// Load protected image
const { imageSrc, loading, error } = useProtectedImage(thumbnailUrl);
// Determine card classes based on view mode // Determine card classes based on view mode
const getCardClasses = () => { const getCardClasses = () => {
const baseClasses = 'card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow group relative'; const baseClasses = 'card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow group relative';
@@ -74,51 +133,67 @@ const DocumentCard = memo(({document, viewMode, onEdit, onDelete}) => {
}; };
// Render thumbnail with hover actions // Render thumbnail with hover actions
const renderThumbnail = () => ( const renderThumbnail = () => {
<figure className="relative overflow-hidden"> const heightClass = viewMode === 'small' ? 'h-48' : viewMode === 'large' ? 'h-64' : 'h-64';
<img
src={buildFullUrl(thumbnailUrl)}
alt={`${name}`}
className={`w-[200px] object-cover ${
viewMode === 'small' ? 'h-32' : viewMode === 'large' ? 'h-48' : 'h-64'
}`}
loading="lazy"
/>
{/* Hover overlay with actions */} return (
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <figure className="relative overflow-hidden">
<button {loading ? (
className="btn btn-sm btn-circle btn-primary" <div className={`w-[200px] ${heightClass} bg-gray-200 animate-pulse flex items-center justify-center`}>
onClick={onEdit} <svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
aria-label="Edit document" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
title="Edit" </svg>
> </div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" ) : error ? (
stroke="currentColor"> <div className={`w-[200px] ${heightClass} bg-gray-300 flex flex-col items-center justify-center`}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <svg className="w-8 h-8 text-gray-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</button> <span className="text-gray-500 text-xs">Failed to load</span>
<button </div>
className="btn btn-sm btn-circle btn-error" ) : (
onClick={onDelete} <img
aria-label="Delete document" src={imageSrc}
title="Delete" alt={`${name}`}
> className={`object-cover ${heightClass}`}
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" />
stroke="currentColor"> )}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
{/* File type badge */} {/* Hover overlay with actions */}
<div className="absolute bottom-2 left-2"> <div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="badge badge-accent badge-sm">{originalFileType}</span> <button
</div> className="btn btn-sm btn-circle btn-primary"
</figure> onClick={onEdit}
); aria-label="Edit document"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button
className="btn btn-sm btn-circle btn-error"
onClick={onDelete}
aria-label="Delete document"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
{/* File type badge */}
<div className="absolute bottom-2 left-2">
<span className="badge badge-accent badge-sm">{originalFileType}</span>
</div>
</figure>
);
};
// Render card body based on view mode // Render card body based on view mode
const renderCardBody = () => { const renderCardBody = () => {

View File

@@ -90,7 +90,7 @@ const DocumentGallery = () => {
// Loading state // Loading state
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center min-h-[400px]"> <div className="flex justify-center items-center min-h-[400px] ">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
</div> </div>
); );
@@ -122,11 +122,10 @@ const DocumentGallery = () => {
} }
return ( return (
<div> <div className="h-full flex flex-col">
{/* Header with view mode switcher */} {/* Header with view mode switcher - Always visible */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6 flex-shrink-0">
<div> <div>
<h2 className="text-2xl font-bold">Documents</h2>
<p className="text-gray-500">{documents.length} document{documents.length !== 1 ? 's' : ''}</p> <p className="text-gray-500">{documents.length} document{documents.length !== 1 ? 's' : ''}</p>
</div> </div>
<ViewModeSwitcher <ViewModeSwitcher
@@ -135,26 +134,28 @@ const DocumentGallery = () => {
/> />
</div> </div>
{/* Document grid/list */} {/* Document grid/list - Scrollable */}
<div className={getGridClasses()}> <div className="flex-1 overflow-y-auto">
{documents.map(document => ( <div className={getGridClasses()}>
viewMode === 'detail' ? ( {documents.map(document => (
<DocumentDetailView viewMode === 'detail' ? (
key={document.id} <DocumentDetailView
document={document} key={document.id}
onEdit={() => handleEditClick(document)} document={document}
onDelete={() => handleDeleteClick(document)} onEdit={() => handleEditClick(document)}
/> onDelete={() => handleDeleteClick(document)}
) : ( />
<DocumentCard ) : (
key={document.id} <DocumentCard
document={document} key={document.id}
viewMode={viewMode} document={document}
onEdit={() => handleEditClick(document)} viewMode={viewMode}
onDelete={() => handleDeleteClick(document)} onEdit={() => handleEditClick(document)}
/> onDelete={() => handleDeleteClick(document)}
) />
))} )
))}
</div>
</div> </div>
{/* Modals */} {/* Modals */}

View File

@@ -4,6 +4,8 @@
*/ */
import React from 'react'; import React from 'react';
import {FaList} from "react-icons/fa6";
import {FaTh, FaThLarge} from "react-icons/fa";
/** /**
* @typedef {'small' | 'large' | 'detail'} ViewMode * @typedef {'small' | 'large' | 'detail'} ViewMode
@@ -18,27 +20,30 @@ import React from 'react';
*/ */
const ViewModeSwitcher = ({ currentMode, onModeChange }) => { const ViewModeSwitcher = ({ currentMode, onModeChange }) => {
const modes = [ const modes = [
{ id: 'small', label: 'Small', icon: '⊞' }, { id: 'small', label: 'Small', icon: FaTh },
{ id: 'large', label: 'Large', icon: '⊡' }, { id: 'large', label: 'Large', icon: FaThLarge },
{ id: 'detail', label: 'Detail', icon: '☰' } { id: 'detail', label: 'Detail', icon: FaList }
]; ];
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
{modes.map(mode => ( {modes.map(mode => {
<button const IconComponent = mode.icon;
key={mode.id} return (
onClick={() => onModeChange(mode.id)} <button
className={`btn btn-sm ${ key={mode.id}
currentMode === mode.id ? 'btn-primary' : 'btn-ghost' onClick={() => onModeChange(mode.id)}
}`} className={`btn btn-sm ${
aria-label={`Switch to ${mode.label} view`} currentMode === mode.id ? 'btn-primary' : 'btn-ghost'
title={`${mode.label} view`} }`}
> aria-label={`Switch to ${mode.label} view`}
<span className="text-lg">{mode.icon}</span> title={`${mode.label} view`}
<span className="hidden sm:inline ml-1">{mode.label}</span> >
</button> <IconComponent />
))} <span className="hidden sm:inline ml-1">{mode.label}</span>
</button>
);
})}
</div> </div>
); );
}; };

View File

@@ -12,7 +12,7 @@ import DocumentGallery from '../components/documents/DocumentGallery';
*/ */
const DocumentsPage = () => { const DocumentsPage = () => {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="h-full flex flex-col">
<DocumentGallery /> <DocumentGallery />
</div> </div>
); );

View File

@@ -0,0 +1,739 @@
"""
Unit tests for UserService using in-memory MongoDB.
Tests the business logic operations with real MongoDB operations
using mongomock for better integration testing.
"""
import pytest
from bson import ObjectId
from mongomock.mongo_client import MongoClient
from app.models.auth import UserRole
from app.models.user import UserCreate, UserUpdate, UserCreateNoValidation
from app.services.user_service import UserService
@pytest.fixture
def in_memory_database():
"""Create an in-memory database for testing."""
client = MongoClient()
return client.test_database
@pytest.fixture
def user_service(in_memory_database):
"""Create UserService with in-memory repositories."""
service = UserService(in_memory_database).initialize()
return service
@pytest.fixture
def sample_user_data():
"""Sample user data for testing."""
return {
"username": "testuser",
"email": "testuser@example.com",
"password": "SecureP@ssw0rd123"
}
@pytest.fixture
def sample_user_data_2():
"""Second sample user data for testing."""
return {
"username": "anotheruser",
"email": "anotheruser@example.com",
"password": "AnotherP@ssw0rd456"
}
class TestCreateUser:
"""Tests for create_user method."""
def test_i_can_create_user_with_valid_data(
self,
user_service,
sample_user_data
):
"""Test creating user with valid data."""
# Execute
user_create = UserCreate(**sample_user_data)
result = user_service.create_user(user_create)
# Verify user creation
assert result is not None
assert result.username == sample_user_data["username"]
assert result.email == sample_user_data["email"]
assert result.hashed_password is not None
assert result.hashed_password != sample_user_data["password"]
assert result.role == UserRole.USER
assert result.is_active is True
assert result.preferences == {}
assert result.created_at is not None
assert result.updated_at is not None
# Verify user exists in database
user_in_db = user_service.get_user_by_id(str(result.id))
assert user_in_db is not None
assert user_in_db.id == result.id
assert user_in_db.username == sample_user_data["username"]
def test_i_cannot_create_user_with_duplicate_username(
self,
user_service,
sample_user_data
):
"""Test that duplicate username raises ValueError."""
# Create first user
user_create = UserCreate(**sample_user_data)
user_service.create_user(user_create)
# Try to create user with same username but different email
duplicate_user_data = sample_user_data.copy()
duplicate_user_data["email"] = "different@example.com"
duplicate_user_create = UserCreate(**duplicate_user_data)
# Execute and verify exception
with pytest.raises(ValueError) as exc_info:
user_service.create_user(duplicate_user_create)
assert "already exists" in str(exc_info.value)
assert sample_user_data["username"] in str(exc_info.value)
def test_i_cannot_create_user_with_duplicate_email(
self,
user_service,
sample_user_data
):
"""Test that duplicate email raises ValueError."""
# Create first user
user_create = UserCreate(**sample_user_data)
user_service.create_user(user_create)
# Try to create user with same email but different username
duplicate_user_data = sample_user_data.copy()
duplicate_user_data["username"] = "differentuser"
duplicate_user_create = UserCreate(**duplicate_user_data)
# Execute and verify exception
with pytest.raises(ValueError) as exc_info:
user_service.create_user(duplicate_user_create)
assert "already exists" in str(exc_info.value)
assert sample_user_data["email"] in str(exc_info.value)
class TestGetUserMethods:
"""Tests for user retrieval methods."""
def test_i_can_get_user_by_username(
self,
user_service,
sample_user_data
):
"""Test retrieving user by username."""
# Create a user first
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute
result = user_service.get_user_by_username(sample_user_data["username"])
# Verify
assert result is not None
assert result.id == created_user.id
assert result.username == sample_user_data["username"]
assert result.email == sample_user_data["email"]
def test_i_can_get_user_by_id(
self,
user_service,
sample_user_data
):
"""Test retrieving user by ID."""
# Create a user first
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute
result = user_service.get_user_by_id(str(created_user.id))
# Verify
assert result is not None
assert result.id == created_user.id
assert result.username == sample_user_data["username"]
assert result.email == sample_user_data["email"]
def test_i_can_check_user_exists(
self,
user_service,
sample_user_data
):
"""Test checking if user exists."""
# Initially should not exist
assert user_service.user_exists(sample_user_data["username"]) is False
# Create a user
user_create = UserCreate(**sample_user_data)
user_service.create_user(user_create)
# Now should exist
assert user_service.user_exists(sample_user_data["username"]) is True
def test_i_cannot_get_nonexistent_user_by_username(
self,
user_service
):
"""Test retrieving nonexistent user by username returns None."""
# Execute
result = user_service.get_user_by_username("nonexistentuser")
# Verify
assert result is None
def test_i_cannot_get_nonexistent_user_by_id(
self,
user_service
):
"""Test retrieving nonexistent user by ID returns None."""
# Execute with random ObjectId
result = user_service.get_user_by_id(str(ObjectId()))
# Verify
assert result is None
class TestAuthenticateUser:
"""Tests for authenticate_user method."""
def test_i_can_authenticate_user_with_valid_credentials(
self,
user_service,
sample_user_data
):
"""Test authenticating user with valid credentials."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute authentication
result = user_service.authenticate_user(
sample_user_data["username"],
sample_user_data["password"]
)
# Verify
assert result is not None
assert result.id == created_user.id
assert result.username == sample_user_data["username"]
def test_i_cannot_authenticate_user_with_wrong_password(
self,
user_service,
sample_user_data
):
"""Test authenticating user with wrong password returns None."""
# Create a user
user_create = UserCreate(**sample_user_data)
user_service.create_user(user_create)
# Execute authentication with wrong password
result = user_service.authenticate_user(
sample_user_data["username"],
"WrongP@ssw0rd123"
)
# Verify
assert result is None
def test_i_cannot_authenticate_user_with_wrong_username(
self,
user_service,
sample_user_data
):
"""Test authenticating user with wrong username returns None."""
# Create a user
user_create = UserCreate(**sample_user_data)
user_service.create_user(user_create)
# Execute authentication with wrong username
result = user_service.authenticate_user(
"wrongusername",
sample_user_data["password"]
)
# Verify
assert result is None
def test_i_cannot_authenticate_inactive_user(
self,
user_service,
sample_user_data
):
"""Test authenticating inactive user returns None."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Deactivate the user
user_service.update_user(str(created_user.id), UserUpdate(is_active=False))
# Execute authentication
result = user_service.authenticate_user(
sample_user_data["username"],
sample_user_data["password"]
)
# Verify
assert result is None
class TestUpdateUser:
"""Tests for update_user method."""
def test_i_can_update_user_username(
self,
user_service,
sample_user_data
):
"""Test updating user username."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute update
new_username = "updatedusername"
result = user_service.update_user(
str(created_user.id),
UserUpdate(username=new_username)
)
# Verify
assert result is not None
assert result.username == new_username
# Verify in database
updated_user = user_service.get_user_by_id(str(created_user.id))
assert updated_user.username == new_username
def test_i_can_update_user_email(
self,
user_service,
sample_user_data
):
"""Test updating user email."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute update
new_email = "newemail@example.com"
result = user_service.update_user(
str(created_user.id),
UserUpdate(email=new_email)
)
# Verify
assert result is not None
assert result.email == new_email
# Verify in database
updated_user = user_service.get_user_by_id(str(created_user.id))
assert updated_user.email == new_email
def test_i_can_update_user_role(
self,
user_service,
sample_user_data
):
"""Test updating user role."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute update
result = user_service.update_user(
str(created_user.id),
UserUpdate(role=UserRole.ADMIN)
)
# Verify
assert result is not None
assert result.role == UserRole.ADMIN
# Verify in database
updated_user = user_service.get_user_by_id(str(created_user.id))
assert updated_user.role == UserRole.ADMIN
def test_i_can_update_user_is_active(
self,
user_service,
sample_user_data
):
"""Test updating user is_active status."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute update
result = user_service.update_user(
str(created_user.id),
UserUpdate(is_active=False)
)
# Verify
assert result is not None
assert result.is_active is False
# Verify in database
updated_user = user_service.get_user_by_id(str(created_user.id))
assert updated_user.is_active is False
def test_i_cannot_update_user_with_duplicate_username(
self,
user_service,
sample_user_data,
sample_user_data_2
):
"""Test that updating to existing username raises ValueError."""
# Create two users
user_create_1 = UserCreate(**sample_user_data)
user_1 = user_service.create_user(user_create_1)
user_create_2 = UserCreate(**sample_user_data_2)
user_2 = user_service.create_user(user_create_2)
# Try to update user_2 with user_1's username
with pytest.raises(ValueError) as exc_info:
user_service.update_user(
str(user_2.id),
UserUpdate(username=sample_user_data["username"])
)
assert "already taken" in str(exc_info.value)
def test_i_cannot_update_user_with_duplicate_email(
self,
user_service,
sample_user_data,
sample_user_data_2
):
"""Test that updating to existing email raises ValueError."""
# Create two users
user_create_1 = UserCreate(**sample_user_data)
user_1 = user_service.create_user(user_create_1)
user_create_2 = UserCreate(**sample_user_data_2)
user_2 = user_service.create_user(user_create_2)
# Try to update user_2 with user_1's email
with pytest.raises(ValueError) as exc_info:
user_service.update_user(
str(user_2.id),
UserUpdate(email=sample_user_data["email"])
)
assert "already taken" in str(exc_info.value)
def test_i_cannot_update_nonexistent_user(
self,
user_service
):
"""Test updating nonexistent user returns None."""
# Execute update with random ObjectId
result = user_service.update_user(
str(ObjectId()),
UserUpdate(username="newusername")
)
# Verify
assert result is None
class TestDeleteUser:
"""Tests for delete_user method."""
def test_i_can_delete_existing_user(
self,
user_service,
sample_user_data
):
"""Test deleting an existing user."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Verify user exists
user_before_delete = user_service.get_user_by_id(str(created_user.id))
assert user_before_delete is not None
# Execute deletion
result = user_service.delete_user(str(created_user.id))
# Verify deletion
assert result is True
# Verify user no longer exists
deleted_user = user_service.get_user_by_id(str(created_user.id))
assert deleted_user is None
def test_i_cannot_delete_nonexistent_user(
self,
user_service
):
"""Test deleting a nonexistent user returns False."""
# Execute deletion with random ObjectId
result = user_service.delete_user(str(ObjectId()))
# Verify
assert result is False
class TestListAndCountMethods:
"""Tests for list_users and count_users methods."""
def test_i_can_list_users(
self,
user_service,
sample_user_data,
sample_user_data_2
):
"""Test listing all users."""
# Create multiple users
user_create_1 = UserCreate(**sample_user_data)
user_1 = user_service.create_user(user_create_1)
user_create_2 = UserCreate(**sample_user_data_2)
user_2 = user_service.create_user(user_create_2)
# Execute
result = user_service.list_users()
# Verify
assert len(result) == 2
usernames = [user.username for user in result]
assert sample_user_data["username"] in usernames
assert sample_user_data_2["username"] in usernames
def test_i_can_list_users_with_pagination(
self,
user_service
):
"""Test listing users with pagination."""
# Create 5 users
for i in range(5):
user_data = UserCreateNoValidation(
username=f"user{i}",
email=f"user{i}@example.com",
password="SecureP@ssw0rd123"
)
user_service.create_user(user_data)
# Test skip and limit
result_page_1 = user_service.list_users(skip=0, limit=2)
assert len(result_page_1) == 2
result_page_2 = user_service.list_users(skip=2, limit=2)
assert len(result_page_2) == 2
result_page_3 = user_service.list_users(skip=4, limit=2)
assert len(result_page_3) == 1
# Verify different users in each page
page_1_usernames = [user.username for user in result_page_1]
page_2_usernames = [user.username for user in result_page_2]
assert page_1_usernames != page_2_usernames
def test_i_can_count_users(
self,
user_service,
sample_user_data,
sample_user_data_2
):
"""Test counting users."""
# Initially no users
assert user_service.count_users() == 0
# Create first user
user_create_1 = UserCreate(**sample_user_data)
user_service.create_user(user_create_1)
assert user_service.count_users() == 1
# Create second user
user_create_2 = UserCreate(**sample_user_data_2)
user_service.create_user(user_create_2)
assert user_service.count_users() == 2
def test_list_users_returns_empty_list_when_no_users(
self,
user_service
):
"""Test listing users returns empty list when no users exist."""
# Execute
result = user_service.list_users()
# Verify
assert result == []
class TestUserPreferences:
"""Tests for user preferences methods."""
def test_i_can_get_user_preference(
self,
user_service,
sample_user_data
):
"""Test getting user preference."""
# Create a user with preferences
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Set a preference
user_service.set_preference(str(created_user.id), "theme", "dark")
# Execute
result = user_service.get_preference(str(created_user.id), "theme")
# Verify
assert result == "dark"
def test_i_can_set_user_preference(
self,
user_service,
sample_user_data
):
"""Test setting user preference."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute
result = user_service.set_preference(str(created_user.id), "language", "fr")
# Verify
assert result is not None
assert result.preferences.get("language") == "fr"
# Verify in database
updated_user = user_service.get_user_by_id(str(created_user.id))
assert updated_user.preferences.get("language") == "fr"
def test_i_cannot_get_preference_for_nonexistent_user(
self,
user_service
):
"""Test getting preference for nonexistent user returns None."""
# Execute with random ObjectId
result = user_service.get_preference(str(ObjectId()), "theme")
# Verify
assert result is None
def test_i_cannot_set_preference_for_nonexistent_user(
self,
user_service
):
"""Test setting preference for nonexistent user returns None."""
# Execute with random ObjectId
result = user_service.set_preference(str(ObjectId()), "theme", "dark")
# Verify
assert result is None
def test_get_preference_returns_none_for_nonexistent_key(
self,
user_service,
sample_user_data
):
"""Test getting nonexistent preference key returns None."""
# Create a user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
# Execute
result = user_service.get_preference(str(created_user.id), "nonexistent_key")
# Verify
assert result is None
class TestUserLifecycle:
"""Tests for complete user lifecycle scenarios."""
def test_complete_user_lifecycle(
self,
user_service,
sample_user_data
):
"""Test complete user lifecycle: create → authenticate → update → preferences → delete."""
# Create user
user_create = UserCreate(**sample_user_data)
created_user = user_service.create_user(user_create)
assert created_user is not None
assert created_user.username == sample_user_data["username"]
# Authenticate user
authenticated_user = user_service.authenticate_user(
sample_user_data["username"],
sample_user_data["password"]
)
assert authenticated_user is not None
assert authenticated_user.id == created_user.id
# Update user
updated_user = user_service.update_user(
str(created_user.id),
UserUpdate(role=UserRole.ADMIN)
)
assert updated_user.role == UserRole.ADMIN
# Set preference
user_with_pref = user_service.set_preference(
str(created_user.id),
"theme",
"dark"
)
assert user_with_pref.preferences.get("theme") == "dark"
# Get preference
pref_value = user_service.get_preference(str(created_user.id), "theme")
assert pref_value == "dark"
# Delete user
delete_result = user_service.delete_user(str(created_user.id))
assert delete_result is True
# Verify user no longer exists
deleted_user = user_service.get_user_by_id(str(created_user.id))
assert deleted_user is None
def test_user_operations_with_empty_database(
self,
user_service
):
"""Test user operations when database is empty."""
# Try to get nonexistent user
result = user_service.get_user_by_id(str(ObjectId()))
assert result is None
# Try to get user by username
result = user_service.get_user_by_username("nonexistent")
assert result is None
# Try to list users
users = user_service.list_users()
assert users == []
# Try to count users
count = user_service.count_users()
assert count == 0
# Try to delete nonexistent user
delete_result = user_service.delete_user(str(ObjectId()))
assert delete_result is False
# Try to check user existence
exists = user_service.user_exists("nonexistent")
assert exists is False