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.
Files changed (33) hide show
  1. chewy_attachment/__init__.py +3 -0
  2. chewy_attachment/core/__init__.py +21 -0
  3. chewy_attachment/core/exceptions.py +38 -0
  4. chewy_attachment/core/permissions.py +136 -0
  5. chewy_attachment/core/schemas.py +61 -0
  6. chewy_attachment/core/storage.py +158 -0
  7. chewy_attachment/core/utils.py +56 -0
  8. chewy_attachment/django_app/__init__.py +3 -0
  9. chewy_attachment/django_app/admin.py +16 -0
  10. chewy_attachment/django_app/apps.py +11 -0
  11. chewy_attachment/django_app/migrations/0001_initial.py +33 -0
  12. chewy_attachment/django_app/migrations/__init__.py +0 -0
  13. chewy_attachment/django_app/models.py +64 -0
  14. chewy_attachment/django_app/permissions.py +36 -0
  15. chewy_attachment/django_app/serializers.py +35 -0
  16. chewy_attachment/django_app/tests/__init__.py +0 -0
  17. chewy_attachment/django_app/tests/conftest.py +56 -0
  18. chewy_attachment/django_app/tests/test_views.py +207 -0
  19. chewy_attachment/django_app/urls.py +14 -0
  20. chewy_attachment/django_app/views.py +202 -0
  21. chewy_attachment/fastapi_app/__init__.py +5 -0
  22. chewy_attachment/fastapi_app/crud.py +93 -0
  23. chewy_attachment/fastapi_app/dependencies.py +190 -0
  24. chewy_attachment/fastapi_app/models.py +51 -0
  25. chewy_attachment/fastapi_app/router.py +134 -0
  26. chewy_attachment/fastapi_app/schemas.py +31 -0
  27. chewy_attachment/fastapi_app/tests/__init__.py +0 -0
  28. chewy_attachment/fastapi_app/tests/conftest.py +113 -0
  29. chewy_attachment/fastapi_app/tests/test_router.py +182 -0
  30. chewy_attachment-0.1.0.dist-info/METADATA +384 -0
  31. chewy_attachment-0.1.0.dist-info/RECORD +33 -0
  32. chewy_attachment-0.1.0.dist-info/WHEEL +4 -0
  33. 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