chewy-attachment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chewy_attachment/__init__.py +3 -0
- chewy_attachment/core/__init__.py +21 -0
- chewy_attachment/core/exceptions.py +38 -0
- chewy_attachment/core/permissions.py +136 -0
- chewy_attachment/core/schemas.py +61 -0
- chewy_attachment/core/storage.py +158 -0
- chewy_attachment/core/utils.py +56 -0
- chewy_attachment/django_app/__init__.py +3 -0
- chewy_attachment/django_app/admin.py +16 -0
- chewy_attachment/django_app/apps.py +11 -0
- chewy_attachment/django_app/migrations/0001_initial.py +33 -0
- chewy_attachment/django_app/migrations/__init__.py +0 -0
- chewy_attachment/django_app/models.py +64 -0
- chewy_attachment/django_app/permissions.py +36 -0
- chewy_attachment/django_app/serializers.py +35 -0
- chewy_attachment/django_app/tests/__init__.py +0 -0
- chewy_attachment/django_app/tests/conftest.py +56 -0
- chewy_attachment/django_app/tests/test_views.py +207 -0
- chewy_attachment/django_app/urls.py +14 -0
- chewy_attachment/django_app/views.py +202 -0
- chewy_attachment/fastapi_app/__init__.py +5 -0
- chewy_attachment/fastapi_app/crud.py +93 -0
- chewy_attachment/fastapi_app/dependencies.py +190 -0
- chewy_attachment/fastapi_app/models.py +51 -0
- chewy_attachment/fastapi_app/router.py +134 -0
- chewy_attachment/fastapi_app/schemas.py +31 -0
- chewy_attachment/fastapi_app/tests/__init__.py +0 -0
- chewy_attachment/fastapi_app/tests/conftest.py +113 -0
- chewy_attachment/fastapi_app/tests/test_router.py +182 -0
- chewy_attachment-0.1.0.dist-info/METADATA +384 -0
- chewy_attachment-0.1.0.dist-info/RECORD +33 -0
- chewy_attachment-0.1.0.dist-info/WHEEL +4 -0
- chewy_attachment-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Dependency injection for ChewyAttachment FastAPI app"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Generator, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
7
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
8
|
+
|
|
9
|
+
from ..core.permissions import PermissionChecker
|
|
10
|
+
from ..core.schemas import UserContext
|
|
11
|
+
from ..core.storage import FileStorageEngine
|
|
12
|
+
from .crud import get_attachment
|
|
13
|
+
from .models import Attachment
|
|
14
|
+
|
|
15
|
+
_engine = None
|
|
16
|
+
_storage_root: Optional[Path] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure(database_url: str, storage_root: str | Path) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Configure database and storage for the FastAPI app.
|
|
22
|
+
|
|
23
|
+
Must be called before using the app.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
database_url: SQLAlchemy database URL
|
|
27
|
+
storage_root: Root directory for file storage
|
|
28
|
+
"""
|
|
29
|
+
global _engine, _storage_root
|
|
30
|
+
|
|
31
|
+
_engine = create_engine(database_url, echo=False)
|
|
32
|
+
SQLModel.metadata.create_all(_engine)
|
|
33
|
+
_storage_root = Path(storage_root)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_engine():
|
|
37
|
+
"""Get database engine"""
|
|
38
|
+
if _engine is None:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"Database not configured. Call configure() first."
|
|
41
|
+
)
|
|
42
|
+
return _engine
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_session() -> Generator[Session, None, None]:
|
|
46
|
+
"""
|
|
47
|
+
Get database session dependency.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
Database session
|
|
51
|
+
"""
|
|
52
|
+
engine = get_engine()
|
|
53
|
+
with Session(engine) as session:
|
|
54
|
+
yield session
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_storage_engine() -> FileStorageEngine:
|
|
58
|
+
"""
|
|
59
|
+
Get storage engine dependency.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
FileStorageEngine instance
|
|
63
|
+
"""
|
|
64
|
+
if _storage_root is None:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"Storage not configured. Call configure() first."
|
|
67
|
+
)
|
|
68
|
+
return FileStorageEngine(_storage_root)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_current_user(request: Request) -> UserContext:
|
|
72
|
+
"""
|
|
73
|
+
Get current user from request.
|
|
74
|
+
|
|
75
|
+
This dependency should be overridden by the host application
|
|
76
|
+
to provide actual user authentication.
|
|
77
|
+
|
|
78
|
+
By default, it checks for user_id in request.state.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
request: FastAPI request
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
UserContext instance
|
|
85
|
+
"""
|
|
86
|
+
if hasattr(request.state, "user_id") and request.state.user_id:
|
|
87
|
+
return UserContext.authenticated(str(request.state.user_id))
|
|
88
|
+
|
|
89
|
+
return UserContext.anonymous()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_current_user_required(
|
|
93
|
+
user: UserContext = Depends(get_current_user),
|
|
94
|
+
) -> UserContext:
|
|
95
|
+
"""
|
|
96
|
+
Get current user, requiring authentication.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user: User context from get_current_user
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
UserContext instance
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
HTTPException: If user is not authenticated
|
|
106
|
+
"""
|
|
107
|
+
if not user.is_authenticated:
|
|
108
|
+
raise HTTPException(
|
|
109
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
110
|
+
detail="Authentication required",
|
|
111
|
+
)
|
|
112
|
+
return user
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_attachment_or_404(
|
|
116
|
+
attachment_id: str,
|
|
117
|
+
session: Session = Depends(get_session),
|
|
118
|
+
) -> Attachment:
|
|
119
|
+
"""
|
|
120
|
+
Get attachment by ID or raise 404.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
attachment_id: Attachment ID
|
|
124
|
+
session: Database session
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Attachment instance
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
HTTPException: If attachment not found
|
|
131
|
+
"""
|
|
132
|
+
attachment = get_attachment(session, attachment_id)
|
|
133
|
+
if attachment is None:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
136
|
+
detail="Attachment not found",
|
|
137
|
+
)
|
|
138
|
+
return attachment
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def require_view_permission(
|
|
142
|
+
attachment: Attachment = Depends(get_attachment_or_404),
|
|
143
|
+
user: UserContext = Depends(get_current_user),
|
|
144
|
+
) -> Attachment:
|
|
145
|
+
"""
|
|
146
|
+
Require view permission for attachment.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
attachment: Attachment instance
|
|
150
|
+
user: User context
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Attachment instance if permitted
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
HTTPException: If permission denied
|
|
157
|
+
"""
|
|
158
|
+
file_metadata = attachment.to_file_metadata()
|
|
159
|
+
if not PermissionChecker.can_view(file_metadata, user):
|
|
160
|
+
raise HTTPException(
|
|
161
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
162
|
+
detail="You do not have permission to view this file",
|
|
163
|
+
)
|
|
164
|
+
return attachment
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def require_delete_permission(
|
|
168
|
+
attachment: Attachment = Depends(get_attachment_or_404),
|
|
169
|
+
user: UserContext = Depends(get_current_user),
|
|
170
|
+
) -> Attachment:
|
|
171
|
+
"""
|
|
172
|
+
Require delete permission for attachment.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
attachment: Attachment instance
|
|
176
|
+
user: User context
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Attachment instance if permitted
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
HTTPException: If permission denied
|
|
183
|
+
"""
|
|
184
|
+
file_metadata = attachment.to_file_metadata()
|
|
185
|
+
if not PermissionChecker.can_delete(file_metadata, user):
|
|
186
|
+
raise HTTPException(
|
|
187
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
188
|
+
detail="Only the file owner can delete this file",
|
|
189
|
+
)
|
|
190
|
+
return attachment
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""SQLModel models for ChewyAttachment FastAPI app"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlmodel import Field, SQLModel
|
|
7
|
+
|
|
8
|
+
from ..core.schemas import FileMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Attachment(SQLModel, table=True):
|
|
12
|
+
"""Attachment model for storing file metadata"""
|
|
13
|
+
|
|
14
|
+
__tablename__ = "chewy_attachment_files"
|
|
15
|
+
|
|
16
|
+
id: str = Field(
|
|
17
|
+
default_factory=lambda: str(uuid.uuid4()),
|
|
18
|
+
primary_key=True,
|
|
19
|
+
max_length=36,
|
|
20
|
+
)
|
|
21
|
+
original_name: str = Field(max_length=255)
|
|
22
|
+
storage_path: str = Field(max_length=500)
|
|
23
|
+
mime_type: str = Field(max_length=100)
|
|
24
|
+
size: int
|
|
25
|
+
owner_id: str = Field(max_length=100, index=True)
|
|
26
|
+
is_public: bool = Field(default=False, index=True)
|
|
27
|
+
created_at: datetime = Field(default_factory=datetime.now, index=True)
|
|
28
|
+
|
|
29
|
+
def to_file_metadata(self) -> FileMetadata:
|
|
30
|
+
"""Convert to FileMetadata for permission checking"""
|
|
31
|
+
return FileMetadata(
|
|
32
|
+
id=self.id,
|
|
33
|
+
original_name=self.original_name,
|
|
34
|
+
storage_path=self.storage_path,
|
|
35
|
+
mime_type=self.mime_type,
|
|
36
|
+
size=self.size,
|
|
37
|
+
owner_id=self.owner_id,
|
|
38
|
+
is_public=self.is_public,
|
|
39
|
+
created_at=self.created_at,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AttachmentCreate(SQLModel):
|
|
44
|
+
"""Schema for creating attachment (internal use)"""
|
|
45
|
+
|
|
46
|
+
original_name: str
|
|
47
|
+
storage_path: str
|
|
48
|
+
mime_type: str
|
|
49
|
+
size: int
|
|
50
|
+
owner_id: str
|
|
51
|
+
is_public: bool = False
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""FastAPI router for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
4
|
+
from fastapi.responses import FileResponse
|
|
5
|
+
from sqlmodel import Session
|
|
6
|
+
|
|
7
|
+
from ..core.schemas import UserContext
|
|
8
|
+
from ..core.storage import FileStorageEngine
|
|
9
|
+
from . import crud
|
|
10
|
+
from .dependencies import (
|
|
11
|
+
get_current_user_required,
|
|
12
|
+
get_session,
|
|
13
|
+
get_storage_engine,
|
|
14
|
+
require_delete_permission,
|
|
15
|
+
require_view_permission,
|
|
16
|
+
)
|
|
17
|
+
from .models import Attachment, AttachmentCreate
|
|
18
|
+
from .schemas import AttachmentResponse, ErrorResponse
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/files", tags=["attachments"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.post(
|
|
24
|
+
"",
|
|
25
|
+
response_model=AttachmentResponse,
|
|
26
|
+
status_code=status.HTTP_201_CREATED,
|
|
27
|
+
responses={
|
|
28
|
+
401: {"model": ErrorResponse, "description": "Authentication required"},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
async def upload_file(
|
|
32
|
+
file: UploadFile = File(...),
|
|
33
|
+
is_public: bool = Form(default=False),
|
|
34
|
+
session: Session = Depends(get_session),
|
|
35
|
+
storage: FileStorageEngine = Depends(get_storage_engine),
|
|
36
|
+
user: UserContext = Depends(get_current_user_required),
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Upload a new file.
|
|
40
|
+
|
|
41
|
+
- **file**: File to upload
|
|
42
|
+
- **is_public**: Whether the file should be publicly accessible
|
|
43
|
+
"""
|
|
44
|
+
content = await file.read()
|
|
45
|
+
original_name = file.filename or "unnamed"
|
|
46
|
+
|
|
47
|
+
result = storage.save_file(content, original_name)
|
|
48
|
+
|
|
49
|
+
attachment_data = AttachmentCreate(
|
|
50
|
+
original_name=original_name,
|
|
51
|
+
storage_path=result.storage_path,
|
|
52
|
+
mime_type=result.mime_type,
|
|
53
|
+
size=result.size,
|
|
54
|
+
owner_id=user.user_id,
|
|
55
|
+
is_public=is_public,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
attachment = crud.create_attachment(session, attachment_data)
|
|
59
|
+
return attachment
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.get(
|
|
63
|
+
"/{attachment_id}",
|
|
64
|
+
response_model=AttachmentResponse,
|
|
65
|
+
responses={
|
|
66
|
+
403: {"model": ErrorResponse, "description": "Permission denied"},
|
|
67
|
+
404: {"model": ErrorResponse, "description": "Attachment not found"},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
async def get_file_info(
|
|
71
|
+
attachment: Attachment = Depends(require_view_permission),
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Get file metadata.
|
|
75
|
+
|
|
76
|
+
- **attachment_id**: UUID of the attachment
|
|
77
|
+
"""
|
|
78
|
+
return attachment
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get(
|
|
82
|
+
"/{attachment_id}/content",
|
|
83
|
+
responses={
|
|
84
|
+
403: {"model": ErrorResponse, "description": "Permission denied"},
|
|
85
|
+
404: {"model": ErrorResponse, "description": "Attachment not found"},
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
async def download_file(
|
|
89
|
+
attachment: Attachment = Depends(require_view_permission),
|
|
90
|
+
storage: FileStorageEngine = Depends(get_storage_engine),
|
|
91
|
+
):
|
|
92
|
+
"""
|
|
93
|
+
Download file content.
|
|
94
|
+
|
|
95
|
+
- **attachment_id**: UUID of the attachment
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
file_path = storage.get_file_path(attachment.storage_path)
|
|
99
|
+
except Exception:
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
102
|
+
detail="File not found on storage",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return FileResponse(
|
|
106
|
+
path=file_path,
|
|
107
|
+
media_type=attachment.mime_type,
|
|
108
|
+
filename=attachment.original_name,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.delete(
|
|
113
|
+
"/{attachment_id}",
|
|
114
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
115
|
+
responses={
|
|
116
|
+
403: {"model": ErrorResponse, "description": "Permission denied"},
|
|
117
|
+
404: {"model": ErrorResponse, "description": "Attachment not found"},
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
async def delete_file(
|
|
121
|
+
attachment: Attachment = Depends(require_delete_permission),
|
|
122
|
+
session: Session = Depends(get_session),
|
|
123
|
+
storage: FileStorageEngine = Depends(get_storage_engine),
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Delete a file.
|
|
127
|
+
|
|
128
|
+
Only the file owner can delete the file.
|
|
129
|
+
|
|
130
|
+
- **attachment_id**: UUID of the attachment
|
|
131
|
+
"""
|
|
132
|
+
storage.delete_file(attachment.storage_path)
|
|
133
|
+
crud.delete_attachment(session, attachment)
|
|
134
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Pydantic schemas for ChewyAttachment FastAPI app"""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AttachmentResponse(BaseModel):
|
|
9
|
+
"""Response schema for attachment"""
|
|
10
|
+
|
|
11
|
+
id: str
|
|
12
|
+
original_name: str
|
|
13
|
+
mime_type: str
|
|
14
|
+
size: int
|
|
15
|
+
owner_id: str
|
|
16
|
+
is_public: bool
|
|
17
|
+
created_at: datetime
|
|
18
|
+
|
|
19
|
+
model_config = {"from_attributes": True}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AttachmentUploadForm(BaseModel):
|
|
23
|
+
"""Form data for file upload (excluding file itself)"""
|
|
24
|
+
|
|
25
|
+
is_public: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorResponse(BaseModel):
|
|
29
|
+
"""Error response schema"""
|
|
30
|
+
|
|
31
|
+
detail: str
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""pytest configuration for FastAPI tests"""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
11
|
+
|
|
12
|
+
from chewy_attachment.core.schemas import UserContext
|
|
13
|
+
from chewy_attachment.fastapi_app import dependencies
|
|
14
|
+
from chewy_attachment.fastapi_app.router import router
|
|
15
|
+
|
|
16
|
+
TEST_DIR = Path(__file__).parent.absolute()
|
|
17
|
+
TEST_STORAGE = TEST_DIR / "test_storage"
|
|
18
|
+
TEST_DB = TEST_DIR / "test.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_current_user_id: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_test_current_user() -> UserContext:
|
|
25
|
+
"""Get current user for testing"""
|
|
26
|
+
if _current_user_id is not None:
|
|
27
|
+
return UserContext.authenticated(_current_user_id)
|
|
28
|
+
return UserContext.anonymous()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
32
|
+
def setup_test_environment():
|
|
33
|
+
"""Set up test environment"""
|
|
34
|
+
TEST_STORAGE.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
yield
|
|
37
|
+
|
|
38
|
+
if TEST_STORAGE.exists():
|
|
39
|
+
shutil.rmtree(TEST_STORAGE)
|
|
40
|
+
if TEST_DB.exists():
|
|
41
|
+
TEST_DB.unlink()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(scope="function")
|
|
45
|
+
def db_engine():
|
|
46
|
+
"""Create test database engine"""
|
|
47
|
+
db_path = TEST_DIR / f"test_{id(object())}.db"
|
|
48
|
+
engine = create_engine(
|
|
49
|
+
f"sqlite:///{db_path}",
|
|
50
|
+
connect_args={"check_same_thread": False},
|
|
51
|
+
)
|
|
52
|
+
SQLModel.metadata.create_all(engine)
|
|
53
|
+
|
|
54
|
+
yield engine
|
|
55
|
+
|
|
56
|
+
engine.dispose()
|
|
57
|
+
if db_path.exists():
|
|
58
|
+
db_path.unlink()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def db_session(db_engine):
|
|
63
|
+
"""Create test database session"""
|
|
64
|
+
with Session(db_engine) as session:
|
|
65
|
+
yield session
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def app(db_engine):
|
|
70
|
+
"""Create test FastAPI app"""
|
|
71
|
+
test_app = FastAPI()
|
|
72
|
+
|
|
73
|
+
dependencies._engine = db_engine
|
|
74
|
+
dependencies._storage_root = TEST_STORAGE
|
|
75
|
+
|
|
76
|
+
test_app.include_router(router)
|
|
77
|
+
|
|
78
|
+
test_app.dependency_overrides[dependencies.get_current_user] = _get_test_current_user
|
|
79
|
+
|
|
80
|
+
yield test_app
|
|
81
|
+
|
|
82
|
+
test_app.dependency_overrides.clear()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def client(app):
|
|
87
|
+
"""Create test client"""
|
|
88
|
+
return TestClient(app)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture
|
|
92
|
+
def user1_id():
|
|
93
|
+
"""User 1 ID"""
|
|
94
|
+
return "user-1-test-id"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def user2_id():
|
|
99
|
+
"""User 2 ID"""
|
|
100
|
+
return "user-2-test-id"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.fixture
|
|
104
|
+
def set_current_user() -> Callable[[Optional[str]], None]:
|
|
105
|
+
"""Fixture to set current user for testing"""
|
|
106
|
+
def _set_user(user_id: Optional[str]):
|
|
107
|
+
global _current_user_id
|
|
108
|
+
_current_user_id = user_id
|
|
109
|
+
|
|
110
|
+
yield _set_user
|
|
111
|
+
|
|
112
|
+
global _current_user_id
|
|
113
|
+
_current_user_id = None
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Unit tests for FastAPI router"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import status
|
|
8
|
+
|
|
9
|
+
TEST_DIR = Path(__file__).parent.absolute()
|
|
10
|
+
TEST_STORAGE = TEST_DIR / "test_storage"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestAttachmentRouter:
|
|
14
|
+
"""Test cases for attachment API router"""
|
|
15
|
+
|
|
16
|
+
TEST_FILE_CONTENT = b"Hello, this is test file content!"
|
|
17
|
+
TEST_FILE_NAME = "test.txt"
|
|
18
|
+
|
|
19
|
+
def setup_method(self):
|
|
20
|
+
"""Set up before each test"""
|
|
21
|
+
TEST_STORAGE.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def teardown_method(self):
|
|
24
|
+
"""Clean up after each test"""
|
|
25
|
+
if TEST_STORAGE.exists():
|
|
26
|
+
for item in TEST_STORAGE.iterdir():
|
|
27
|
+
if item.is_dir():
|
|
28
|
+
shutil.rmtree(item)
|
|
29
|
+
else:
|
|
30
|
+
item.unlink()
|
|
31
|
+
|
|
32
|
+
def _upload_file(self, client, is_public: bool = False):
|
|
33
|
+
"""Helper to upload a file"""
|
|
34
|
+
files = {"file": (self.TEST_FILE_NAME, io.BytesIO(self.TEST_FILE_CONTENT))}
|
|
35
|
+
data = {"is_public": str(is_public).lower()}
|
|
36
|
+
|
|
37
|
+
return client.post("/files", files=files, data=data)
|
|
38
|
+
|
|
39
|
+
def test_upload_file_success(self, client, set_current_user, user1_id):
|
|
40
|
+
"""Test successful file upload"""
|
|
41
|
+
set_current_user(user1_id)
|
|
42
|
+
response = self._upload_file(client)
|
|
43
|
+
|
|
44
|
+
assert response.status_code == status.HTTP_201_CREATED
|
|
45
|
+
data = response.json()
|
|
46
|
+
assert data["original_name"] == self.TEST_FILE_NAME
|
|
47
|
+
assert data["size"] == len(self.TEST_FILE_CONTENT)
|
|
48
|
+
assert data["owner_id"] == user1_id
|
|
49
|
+
assert data["is_public"] is False
|
|
50
|
+
|
|
51
|
+
def test_upload_file_public(self, client, set_current_user, user1_id):
|
|
52
|
+
"""Test uploading public file"""
|
|
53
|
+
set_current_user(user1_id)
|
|
54
|
+
response = self._upload_file(client, is_public=True)
|
|
55
|
+
|
|
56
|
+
assert response.status_code == status.HTTP_201_CREATED
|
|
57
|
+
data = response.json()
|
|
58
|
+
assert data["is_public"] is True
|
|
59
|
+
|
|
60
|
+
def test_upload_file_unauthenticated_fails(self, client, set_current_user):
|
|
61
|
+
"""Test upload fails without authentication"""
|
|
62
|
+
set_current_user(None)
|
|
63
|
+
files = {"file": (self.TEST_FILE_NAME, io.BytesIO(self.TEST_FILE_CONTENT))}
|
|
64
|
+
|
|
65
|
+
response = client.post("/files", files=files)
|
|
66
|
+
|
|
67
|
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
68
|
+
|
|
69
|
+
def test_get_file_info_by_owner(self, client, set_current_user, user1_id):
|
|
70
|
+
"""Test owner can get file info"""
|
|
71
|
+
set_current_user(user1_id)
|
|
72
|
+
upload_response = self._upload_file(client)
|
|
73
|
+
file_id = upload_response.json()["id"]
|
|
74
|
+
|
|
75
|
+
response = client.get(f"/files/{file_id}")
|
|
76
|
+
|
|
77
|
+
assert response.status_code == status.HTTP_200_OK
|
|
78
|
+
assert response.json()["id"] == file_id
|
|
79
|
+
|
|
80
|
+
def test_get_private_file_info_by_non_owner_fails(
|
|
81
|
+
self, client, set_current_user, user1_id, user2_id
|
|
82
|
+
):
|
|
83
|
+
"""Test non-owner cannot get private file info"""
|
|
84
|
+
set_current_user(user1_id)
|
|
85
|
+
upload_response = self._upload_file(client, is_public=False)
|
|
86
|
+
file_id = upload_response.json()["id"]
|
|
87
|
+
|
|
88
|
+
set_current_user(user2_id)
|
|
89
|
+
response = client.get(f"/files/{file_id}")
|
|
90
|
+
|
|
91
|
+
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
92
|
+
|
|
93
|
+
def test_get_public_file_info_anonymous(self, client, set_current_user, user1_id):
|
|
94
|
+
"""Test anonymous user can get public file info"""
|
|
95
|
+
set_current_user(user1_id)
|
|
96
|
+
upload_response = self._upload_file(client, is_public=True)
|
|
97
|
+
file_id = upload_response.json()["id"]
|
|
98
|
+
|
|
99
|
+
set_current_user(None)
|
|
100
|
+
response = client.get(f"/files/{file_id}")
|
|
101
|
+
|
|
102
|
+
assert response.status_code == status.HTTP_200_OK
|
|
103
|
+
assert response.json()["id"] == file_id
|
|
104
|
+
|
|
105
|
+
def test_delete_file_by_owner(self, client, set_current_user, user1_id):
|
|
106
|
+
"""Test owner can delete file"""
|
|
107
|
+
set_current_user(user1_id)
|
|
108
|
+
upload_response = self._upload_file(client)
|
|
109
|
+
file_id = upload_response.json()["id"]
|
|
110
|
+
|
|
111
|
+
response = client.delete(f"/files/{file_id}")
|
|
112
|
+
|
|
113
|
+
assert response.status_code == status.HTTP_204_NO_CONTENT
|
|
114
|
+
|
|
115
|
+
def test_delete_file_by_non_owner_fails(
|
|
116
|
+
self, client, set_current_user, user1_id, user2_id
|
|
117
|
+
):
|
|
118
|
+
"""Test non-owner cannot delete file"""
|
|
119
|
+
set_current_user(user1_id)
|
|
120
|
+
upload_response = self._upload_file(client)
|
|
121
|
+
file_id = upload_response.json()["id"]
|
|
122
|
+
|
|
123
|
+
set_current_user(user2_id)
|
|
124
|
+
response = client.delete(f"/files/{file_id}")
|
|
125
|
+
|
|
126
|
+
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
127
|
+
|
|
128
|
+
def test_delete_public_file_by_non_owner_fails(
|
|
129
|
+
self, client, set_current_user, user1_id, user2_id
|
|
130
|
+
):
|
|
131
|
+
"""Test non-owner cannot delete even public file"""
|
|
132
|
+
set_current_user(user1_id)
|
|
133
|
+
upload_response = self._upload_file(client, is_public=True)
|
|
134
|
+
file_id = upload_response.json()["id"]
|
|
135
|
+
|
|
136
|
+
set_current_user(user2_id)
|
|
137
|
+
response = client.delete(f"/files/{file_id}")
|
|
138
|
+
|
|
139
|
+
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
140
|
+
|
|
141
|
+
def test_download_file_by_owner(self, client, set_current_user, user1_id):
|
|
142
|
+
"""Test owner can download file"""
|
|
143
|
+
set_current_user(user1_id)
|
|
144
|
+
upload_response = self._upload_file(client)
|
|
145
|
+
file_id = upload_response.json()["id"]
|
|
146
|
+
|
|
147
|
+
response = client.get(f"/files/{file_id}/content")
|
|
148
|
+
|
|
149
|
+
assert response.status_code == status.HTTP_200_OK
|
|
150
|
+
assert response.content == self.TEST_FILE_CONTENT
|
|
151
|
+
|
|
152
|
+
def test_download_private_file_by_non_owner_fails(
|
|
153
|
+
self, client, set_current_user, user1_id, user2_id
|
|
154
|
+
):
|
|
155
|
+
"""Test non-owner cannot download private file"""
|
|
156
|
+
set_current_user(user1_id)
|
|
157
|
+
upload_response = self._upload_file(client, is_public=False)
|
|
158
|
+
file_id = upload_response.json()["id"]
|
|
159
|
+
|
|
160
|
+
set_current_user(user2_id)
|
|
161
|
+
response = client.get(f"/files/{file_id}/content")
|
|
162
|
+
|
|
163
|
+
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
164
|
+
|
|
165
|
+
def test_download_public_file_anonymous(self, client, set_current_user, user1_id):
|
|
166
|
+
"""Test anonymous user can download public file"""
|
|
167
|
+
set_current_user(user1_id)
|
|
168
|
+
upload_response = self._upload_file(client, is_public=True)
|
|
169
|
+
file_id = upload_response.json()["id"]
|
|
170
|
+
|
|
171
|
+
set_current_user(None)
|
|
172
|
+
response = client.get(f"/files/{file_id}/content")
|
|
173
|
+
|
|
174
|
+
assert response.status_code == status.HTTP_200_OK
|
|
175
|
+
assert response.content == self.TEST_FILE_CONTENT
|
|
176
|
+
|
|
177
|
+
def test_get_nonexistent_file_returns_404(self, client, set_current_user, user1_id):
|
|
178
|
+
"""Test 404 for nonexistent file"""
|
|
179
|
+
set_current_user(user1_id)
|
|
180
|
+
response = client.get("/files/00000000-0000-0000-0000-000000000000")
|
|
181
|
+
|
|
182
|
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|