diff --git a/tests/auth/test_jwt.py b/tests/auth/test_jwt.py index 5eb81ec..0866ec9 100644 --- a/tests/auth/test_jwt.py +++ b/tests/auth/test_jwt.py @@ -52,6 +52,16 @@ class TestJWTAuth: with pytest.raises(ValueError, match="JWT token cannot be empty"): JWTAuth(None, mock_wiki_base_url) + def test_init_with_empty_base_url_raises_error(self, mock_jwt_token): + """Test that empty base URL raises ValueError.""" + with pytest.raises(ValueError, match="Base URL cannot be empty"): + JWTAuth(mock_jwt_token, "") + + def test_init_with_whitespace_base_url_raises_error(self, mock_jwt_token): + """Test that whitespace-only base URL raises ValueError.""" + with pytest.raises(ValueError, match="Base URL cannot be empty"): + JWTAuth(mock_jwt_token, " ") + def test_get_headers_returns_bearer_token(self, jwt_auth, mock_jwt_token): """Test that get_headers returns proper Authorization header.""" headers = jwt_auth.get_headers() diff --git a/tests/endpoints/test_assets.py b/tests/endpoints/test_assets.py index 0ec29dd..d1ba662 100644 --- a/tests/endpoints/test_assets.py +++ b/tests/endpoints/test_assets.py @@ -161,3 +161,347 @@ class TestAssetsEndpoint: with pytest.raises(ValidationError): endpoint.rename(1, "") + + # Move operations + def test_move_asset(self, endpoint): + """Test moving an asset to a different folder.""" + mock_response = { + "data": { + "assets": { + "moveAsset": { + "responseResult": {"succeeded": True}, + "asset": { + "id": 1, + "filename": "test.png", + "ext": "png", + "kind": "image", + "mime": "image/png", + "fileSize": 1024, + "folderId": 5, + "authorId": 1, + "authorName": "Admin", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + }, + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + asset = endpoint.move(1, 5) + + assert asset.folder_id == 5 + + def test_move_asset_validation_error(self, endpoint): + """Test move asset with invalid inputs.""" + with pytest.raises(ValidationError): + endpoint.move(0, 1) # Invalid asset ID + + with pytest.raises(ValidationError): + endpoint.move(1, -1) # Invalid folder ID + + # Folder operations + def test_create_folder(self, endpoint): + """Test creating a folder.""" + mock_response = { + "data": { + "assets": { + "createFolder": { + "responseResult": {"succeeded": True}, + "folder": {"id": 1, "slug": "documents", "name": "Documents"}, + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + folder = endpoint.create_folder("documents", "Documents") + + assert isinstance(folder, AssetFolder) + assert folder.slug == "documents" + assert folder.name == "Documents" + + def test_create_folder_minimal(self, endpoint): + """Test creating a folder with minimal parameters.""" + mock_response = { + "data": { + "assets": { + "createFolder": { + "responseResult": {"succeeded": True}, + "folder": {"id": 2, "slug": "images", "name": None}, + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + folder = endpoint.create_folder("images") + + assert folder.slug == "images" + assert folder.name is None + + def test_create_folder_validation_error(self, endpoint): + """Test create folder with invalid slug.""" + with pytest.raises(ValidationError): + endpoint.create_folder("") # Empty slug + + with pytest.raises(ValidationError): + endpoint.create_folder("///") # Just slashes + + def test_delete_folder(self, endpoint): + """Test deleting a folder.""" + mock_response = { + "data": { + "assets": { + "deleteFolder": { + "responseResult": {"succeeded": True} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + result = endpoint.delete_folder(1) + + assert result is True + + def test_delete_folder_validation_error(self, endpoint): + """Test delete folder with invalid ID.""" + with pytest.raises(ValidationError): + endpoint.delete_folder(0) + + with pytest.raises(ValidationError): + endpoint.delete_folder(-1) + + # List operations with filters + def test_list_assets_with_folder_filter(self, endpoint): + """Test listing assets filtered by folder.""" + mock_response = { + "data": { + "assets": { + "list": [ + { + "id": 1, + "filename": "doc.pdf", + "ext": "pdf", + "kind": "binary", + "mime": "application/pdf", + "fileSize": 2048, + "folderId": 1, + "folder": {"id": 1, "slug": "documents", "name": "Documents"}, + "authorId": 1, + "authorName": "Admin", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + ] + } + } + } + endpoint._post = Mock(return_value=mock_response) + + assets = endpoint.list(folder_id=1) + + assert len(assets) == 1 + assert assets[0].folder_id == 1 + + def test_list_assets_with_kind_filter(self, endpoint): + """Test listing assets filtered by kind.""" + mock_response = { + "data": { + "assets": { + "list": [ + { + "id": 1, + "filename": "photo.jpg", + "ext": "jpg", + "kind": "image", + "mime": "image/jpeg", + "fileSize": 5120, + "folderId": 0, + "authorId": 1, + "authorName": "Admin", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + ] + } + } + } + endpoint._post = Mock(return_value=mock_response) + + assets = endpoint.list(kind="image") + + assert len(assets) == 1 + assert assets[0].kind == "image" + + def test_list_assets_empty(self, endpoint): + """Test listing assets when none exist.""" + mock_response = {"data": {"assets": {"list": []}}} + endpoint._post = Mock(return_value=mock_response) + + assets = endpoint.list() + + assert len(assets) == 0 + + # Error handling + def test_get_asset_not_found(self, endpoint): + """Test getting non-existent asset.""" + mock_response = { + "data": {"assets": {"single": None}}, + "errors": [{"message": "Asset not found"}], + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Asset not found"): + endpoint.get(999) + + def test_delete_asset_failure(self, endpoint): + """Test delete asset API failure.""" + mock_response = { + "data": { + "assets": { + "deleteAsset": { + "responseResult": {"succeeded": False, "message": "Permission denied"} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Permission denied"): + endpoint.delete(1) + + def test_rename_asset_failure(self, endpoint): + """Test rename asset API failure.""" + mock_response = { + "data": { + "assets": { + "renameAsset": { + "responseResult": {"succeeded": False, "message": "Name already exists"} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Name already exists"): + endpoint.rename(1, "duplicate.png") + + def test_move_asset_failure(self, endpoint): + """Test move asset API failure.""" + mock_response = { + "data": { + "assets": { + "moveAsset": { + "responseResult": {"succeeded": False, "message": "Folder not found"} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Folder not found"): + endpoint.move(1, 999) + + def test_create_folder_failure(self, endpoint): + """Test create folder API failure.""" + mock_response = { + "data": { + "assets": { + "createFolder": { + "responseResult": {"succeeded": False, "message": "Folder already exists"} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Folder already exists"): + endpoint.create_folder("existing") + + def test_delete_folder_failure(self, endpoint): + """Test delete folder API failure.""" + mock_response = { + "data": { + "assets": { + "deleteFolder": { + "responseResult": {"succeeded": False, "message": "Folder not empty"} + } + } + } + } + endpoint._post = Mock(return_value=mock_response) + + with pytest.raises(APIError, match="Folder not empty"): + endpoint.delete_folder(1) + + # Pagination + def test_iter_all_assets(self, endpoint): + """Test iterating over all assets with pagination.""" + # First page (smaller batch to ensure pagination works) + mock_response_page1 = { + "data": { + "assets": { + "list": [ + { + "id": i, + "filename": f"file{i}.png", + "ext": "png", + "kind": "image", + "mime": "image/png", + "fileSize": 1024, + "folderId": 0, + "authorId": 1, + "authorName": "Admin", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + for i in range(1, 6) # 5 items + ] + } + } + } + + # Second page (empty - pagination stops) + mock_response_page2 = {"data": {"assets": {"list": []}}} + + endpoint._post = Mock( + side_effect=[mock_response_page1, mock_response_page2] + ) + + all_assets = list(endpoint.iter_all(batch_size=5)) + + assert len(all_assets) == 5 + assert all_assets[0].id == 1 + assert all_assets[4].id == 5 + + # Normalization edge cases + def test_normalize_asset_data_minimal(self, endpoint): + """Test normalizing asset data with minimal fields.""" + data = { + "id": 1, + "filename": "test.png", + "ext": "png", + "kind": "image", + "mime": "image/png", + "fileSize": 1024, + } + + normalized = endpoint._normalize_asset_data(data) + + assert normalized["id"] == 1 + assert normalized["filename"] == "test.png" + # Check that snake_case fields are present + assert "file_size" in normalized + assert normalized["file_size"] == 1024 + + def test_list_folders_empty(self, endpoint): + """Test listing folders when none exist.""" + mock_response = {"data": {"assets": {"folders": []}}} + endpoint._post = Mock(return_value=mock_response) + + folders = endpoint.list_folders() + + assert len(folders) == 0 diff --git a/wikijs/models/asset.py b/wikijs/models/asset.py index ac32bc2..8cc5998 100644 --- a/wikijs/models/asset.py +++ b/wikijs/models/asset.py @@ -2,7 +2,7 @@ from typing import Optional -from pydantic import Field, field_validator +from pydantic import ConfigDict, Field, field_validator from .base import BaseModel, TimestampedModel @@ -10,15 +10,12 @@ from .base import BaseModel, TimestampedModel class AssetFolder(BaseModel): """Asset folder model.""" + model_config = ConfigDict(populate_by_name=True) + id: int = Field(..., description="Folder ID") slug: str = Field(..., description="Folder slug/path") name: Optional[str] = Field(None, description="Folder name") - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class Asset(TimestampedModel): """Wiki.js asset model. @@ -75,10 +72,7 @@ class Asset(TimestampedModel): """Get file size in kilobytes.""" return self.file_size / 1024 - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) class AssetUpload(BaseModel): @@ -102,10 +96,7 @@ class AssetUpload(BaseModel): raise ValueError("File path cannot be empty") return v.strip() - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) class AssetRename(BaseModel): @@ -137,10 +128,7 @@ class AssetRename(BaseModel): raise ValueError("Filename cannot be empty") return v.strip() - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) class AssetMove(BaseModel): @@ -170,10 +158,7 @@ class AssetMove(BaseModel): raise ValueError("Folder ID must be non-negative") return v - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) class FolderCreate(BaseModel): @@ -199,7 +184,4 @@ class FolderCreate(BaseModel): raise ValueError("Slug cannot be just slashes") return v - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) diff --git a/wikijs/models/group.py b/wikijs/models/group.py index 64b246f..56e5fa8 100644 --- a/wikijs/models/group.py +++ b/wikijs/models/group.py @@ -2,7 +2,7 @@ from typing import List, Optional -from pydantic import Field, field_validator +from pydantic import ConfigDict, Field, field_validator from .base import BaseModel, TimestampedModel @@ -10,18 +10,17 @@ from .base import BaseModel, TimestampedModel class GroupPermission(BaseModel): """Group permission model.""" + model_config = ConfigDict(populate_by_name=True) + id: str = Field(..., description="Permission identifier") name: Optional[str] = Field(None, description="Permission name") - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class GroupPageRule(BaseModel): """Group page access rule model.""" + model_config = ConfigDict(populate_by_name=True) + id: str = Field(..., description="Rule identifier") path: str = Field(..., description="Page path pattern") roles: List[str] = Field(default_factory=list, description="Allowed roles") @@ -29,24 +28,16 @@ class GroupPageRule(BaseModel): deny: bool = Field(default=False, description="Whether this is a deny rule") locales: List[str] = Field(default_factory=list, description="Allowed locales") - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class GroupUser(BaseModel): """User member of a group (minimal representation).""" + model_config = ConfigDict(populate_by_name=True) + id: int = Field(..., description="User ID") name: str = Field(..., description="User name") email: str = Field(..., description="User email") - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class Group(TimestampedModel): """Wiki.js group model. @@ -95,10 +86,7 @@ class Group(TimestampedModel): raise ValueError("Group name cannot exceed 255 characters") return v.strip() - class Config: - """Pydantic configuration.""" - - populate_by_name = True + model_config = ConfigDict(populate_by_name=True) class GroupCreate(BaseModel): @@ -111,6 +99,8 @@ class GroupCreate(BaseModel): page_rules: List of page access rule configurations """ + model_config = ConfigDict(populate_by_name=True) + name: str = Field(..., min_length=1, max_length=255, description="Group name") redirect_on_login: Optional[str] = Field( None, alias="redirectOnLogin", description="Redirect path on login" @@ -134,11 +124,6 @@ class GroupCreate(BaseModel): raise ValueError("Group name cannot exceed 255 characters") return v.strip() - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class GroupUpdate(BaseModel): """Model for updating an existing group. @@ -152,6 +137,8 @@ class GroupUpdate(BaseModel): page_rules: Updated page access rules """ + model_config = ConfigDict(populate_by_name=True) + name: Optional[str] = Field( None, min_length=1, max_length=255, description="Group name" ) @@ -177,11 +164,6 @@ class GroupUpdate(BaseModel): raise ValueError("Group name cannot exceed 255 characters") return v.strip() - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class GroupAssignUser(BaseModel): """Model for assigning a user to a group. @@ -191,14 +173,11 @@ class GroupAssignUser(BaseModel): user_id: User ID """ + model_config = ConfigDict(populate_by_name=True) + group_id: int = Field(..., alias="groupId", description="Group ID") user_id: int = Field(..., alias="userId", description="User ID") - class Config: - """Pydantic configuration.""" - - populate_by_name = True - class GroupUnassignUser(BaseModel): """Model for removing a user from a group. @@ -208,10 +187,7 @@ class GroupUnassignUser(BaseModel): user_id: User ID """ + model_config = ConfigDict(populate_by_name=True) + group_id: int = Field(..., alias="groupId", description="Group ID") user_id: int = Field(..., alias="userId", description="User ID") - - class Config: - """Pydantic configuration.""" - - populate_by_name = True diff --git a/wikijs/models/user.py b/wikijs/models/user.py index bb71337..96ee627 100644 --- a/wikijs/models/user.py +++ b/wikijs/models/user.py @@ -3,7 +3,7 @@ import re from typing import List, Optional -from pydantic import EmailStr, Field, field_validator +from pydantic import ConfigDict, EmailStr, Field, field_validator from .base import BaseModel, TimestampedModel @@ -63,11 +63,7 @@ class User(TimestampedModel): return v.strip() - class Config: - """Pydantic model configuration.""" - - populate_by_name = True - str_strip_whitespace = True + model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True) class UserCreate(BaseModel): @@ -122,11 +118,7 @@ class UserCreate(BaseModel): return v - class Config: - """Pydantic model configuration.""" - - populate_by_name = True - str_strip_whitespace = True + model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True) class UserUpdate(BaseModel): @@ -185,8 +177,4 @@ class UserUpdate(BaseModel): return v - class Config: - """Pydantic model configuration.""" - - populate_by_name = True - str_strip_whitespace = True + model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True)