Skip to content

Commit 251b156

Browse files
krystofbeKrystof Beuermann
andauthored
Fix S3Boto3Storage backend and test cases (#415)
* Fix S3Boto3Storage backend and test cases * Refactor file deletion in S3Boto3StorageHealthCheck * Refactor test_storage.py to improve code organization and import statements * Fix import order in s3boto3_storage backend * Add django-storages to test dependencies * Add boto3 to test dependencies * HealthCheckS3Boto3StorageTests only for Django 4.2+ --------- Co-authored-by: Krystof Beuermann <krystof@blackbox.ms>
1 parent 8f7e966 commit 251b156

File tree

4 files changed

+154
-2
lines changed

4 files changed

+154
-2
lines changed

health_check/contrib/s3boto3_storage/backends.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from health_check.exceptions import ServiceUnavailable
34
from health_check.storage.backends import StorageHealthCheck
45

56

@@ -18,7 +19,10 @@ class S3Boto3StorageHealthCheck(StorageHealthCheck):
1819

1920
logger = logging.getLogger(__name__)
2021
storage = "storages.backends.s3boto3.S3Boto3Storage"
22+
storage_alias = "default"
2123

2224
def check_delete(self, file_name):
2325
storage = self.get_storage()
26+
if not storage.exists(file_name):
27+
raise ServiceUnavailable("File does not exist")
2428
storage.delete(file_name)

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ test =
5353
pytest-django
5454
celery
5555
redis
56+
django-storages
57+
boto3
5658
docs =
5759
sphinx
5860

tests/test_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,5 @@ def test_command_with_non_existence_subset(self):
7575
)
7676
stdout.seek(0)
7777
assert stdout.read() == (
78-
f"Specify subset: '{NON_EXISTENCE_SUBSET_NAME}' does not exists.\n"
78+
f"Subset: '{NON_EXISTENCE_SUBSET_NAME}' does not exist.\n"
7979
)

tests/test_storage.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import unittest
2+
from io import BytesIO
23
from unittest import mock
34

45
import django
6+
from django.core.files.base import File
57
from django.core.files.storage import Storage
6-
from django.test import TestCase
8+
from django.test import TestCase, override_settings
79

10+
from health_check.contrib.s3boto3_storage.backends import S3Boto3StorageHealthCheck
811
from health_check.exceptions import ServiceUnavailable
912
from health_check.storage.backends import (
1013
DefaultFileStorageHealthCheck,
@@ -45,6 +48,62 @@ def save(self, name, content, max_length=None):
4548
self.MOCK_FILE_COUNT += 1
4649

4750

51+
# Mocking the S3Boto3Storage backend
52+
class MockS3Boto3Storage:
53+
"""
54+
A mock S3Boto3Storage backend to simulate interactions with AWS S3.
55+
"""
56+
57+
def __init__(self, saves=True, deletes=True):
58+
self.saves = saves
59+
self.deletes = deletes
60+
self.files = {}
61+
62+
def open(self, name, mode="rb"):
63+
"""
64+
Simulates opening a file from the mocked S3 storage.
65+
For simplicity, this doesn't differentiate between read and write modes.
66+
"""
67+
if name in self.files:
68+
# Assuming file content is stored as bytes
69+
file_content = self.files[name]
70+
if isinstance(file_content, bytes):
71+
return File(BytesIO(file_content))
72+
else:
73+
raise ValueError("File content must be bytes.")
74+
else:
75+
raise FileNotFoundError(f"The file {name} does not exist.")
76+
77+
def save(self, name, content):
78+
"""
79+
Overriding save to ensure content is stored as bytes in a way compatible with open method.
80+
Assumes content is either a ContentFile, bytes, or a string that needs conversion.
81+
"""
82+
if self.saves:
83+
# Check if content is a ContentFile or similar and read bytes
84+
if hasattr(content, "read"):
85+
file_content = content.read()
86+
elif isinstance(content, bytes):
87+
file_content = content
88+
elif isinstance(content, str):
89+
file_content = content.encode() # Convert string to bytes
90+
else:
91+
raise ValueError("Unsupported file content type.")
92+
93+
self.files[name] = file_content
94+
return name
95+
raise Exception("Failed to save file.")
96+
97+
def delete(self, name):
98+
if self.deletes:
99+
self.files.pop(name, None)
100+
else:
101+
raise Exception("Failed to delete file.")
102+
103+
def exists(self, name):
104+
return name in self.files
105+
106+
48107
def get_file_name(*args, **kwargs):
49108
return "mockfile.txt"
50109

@@ -156,3 +215,90 @@ def test_file_not_deleted_django_42_plus(self):
156215
default_storage_health = DefaultFileStorageHealthCheck()
157216
with self.assertRaises(ServiceUnavailable):
158217
default_storage_health.check_status()
218+
219+
220+
@mock.patch("storages.backends.s3boto3.S3Boto3Storage", new=MockS3Boto3Storage)
221+
@override_settings(
222+
STORAGES={
223+
"default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"},
224+
}
225+
)
226+
class HealthCheckS3Boto3StorageTests(TestCase):
227+
"""
228+
Tests health check behavior with a mocked S3Boto3Storage backend.
229+
"""
230+
231+
@unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier")
232+
@mock.patch(
233+
"storages.backends.s3boto3.S3Boto3Storage",
234+
MockS3Boto3Storage(deletes=False),
235+
)
236+
def test_check_delete_success_django_41_earlier(self):
237+
"""Test that check_delete correctly deletes a file when S3Boto3Storage is working."""
238+
health_check = S3Boto3StorageHealthCheck()
239+
mock_storage = health_check.get_storage()
240+
file_name = "testfile.txt"
241+
content = BytesIO(b"Test content")
242+
mock_storage.save(file_name, content)
243+
244+
with self.assertRaises(ServiceUnavailable):
245+
health_check.check_delete(file_name)
246+
247+
@unittest.skipUnless((4, 2) <= django.VERSION, "Only for Django 4.2+")
248+
def test_check_delete_success(self):
249+
"""Test that check_delete correctly deletes a file when S3Boto3Storage is working."""
250+
health_check = S3Boto3StorageHealthCheck()
251+
mock_storage = health_check.get_storage()
252+
file_name = "testfile.txt"
253+
content = BytesIO(b"Test content")
254+
mock_storage.save(file_name, content)
255+
256+
health_check.check_delete(file_name)
257+
self.assertFalse(mock_storage.exists(file_name))
258+
259+
def test_check_delete_failure(self):
260+
"""Test that check_delete raises ServiceUnavailable when deletion fails."""
261+
with mock.patch.object(
262+
MockS3Boto3Storage,
263+
"delete",
264+
side_effect=Exception("Failed to delete file."),
265+
):
266+
health_check = S3Boto3StorageHealthCheck()
267+
with self.assertRaises(ServiceUnavailable):
268+
health_check.check_delete("testfile.txt")
269+
270+
@unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier")
271+
@mock.patch(
272+
"storages.backends.s3boto3.S3Boto3Storage",
273+
MockS3Boto3Storage(deletes=False),
274+
)
275+
def test_check_status_working_django_41_earlier(self):
276+
"""Test check_status returns True when S3Boto3Storage can save and delete files."""
277+
health_check = S3Boto3StorageHealthCheck()
278+
with self.assertRaises(ServiceUnavailable):
279+
self.assertTrue(health_check.check_status())
280+
281+
@unittest.skipUnless((4, 2) <= django.VERSION, "Only for Django 4.2+")
282+
def test_check_status_working(self):
283+
"""Test check_status returns True when S3Boto3Storage can save and delete files."""
284+
health_check = S3Boto3StorageHealthCheck()
285+
self.assertTrue(health_check.check_status())
286+
287+
def test_check_status_failure_on_save(self):
288+
"""Test check_status raises ServiceUnavailable when file cannot be saved."""
289+
with mock.patch.object(
290+
MockS3Boto3Storage, "save", side_effect=Exception("Failed to save file.")
291+
):
292+
health_check = S3Boto3StorageHealthCheck()
293+
with self.assertRaises(ServiceUnavailable):
294+
health_check.check_status()
295+
296+
def test_check_status_failure_on_delete(self):
297+
"""Test check_status raises ServiceUnavailable when file cannot be deleted."""
298+
with mock.patch.object(
299+
MockS3Boto3Storage, "exists", new_callable=mock.PropertyMock
300+
) as mock_exists:
301+
mock_exists.return_value = False
302+
health_check = S3Boto3StorageHealthCheck()
303+
with self.assertRaises(ServiceUnavailable):
304+
health_check.check_status()

0 commit comments

Comments
 (0)