Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions server/storage/s3/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from datetime import datetime
from typing import Optional
from lib.logging_utils import init_logger
from server.lib.vcon_redis import VconRedis
Expand Down Expand Up @@ -35,9 +36,23 @@ def _create_s3_client(opts: dict):
return boto3.client("s3", **client_kwargs)


def _build_s3_key(vcon_uuid: str, s3_path: Optional[str] = None) -> str:
"""Build the S3 object key for a vCon."""
key = f"{vcon_uuid}.vcon"
def _build_s3_key(vcon_uuid: str, created_at: Optional[str] = None, s3_path: Optional[str] = None) -> str:
"""Build the S3 object key for a vCon with date-based folder structure.

Args:
vcon_uuid: The vCon UUID
created_at: ISO format timestamp from the vCon's created_at field.
If provided, creates date-based folder structure (YYYY/MM/DD).
s3_path: Optional prefix path in the S3 bucket

Returns:
S3 key in format: [s3_path/][YYYY/MM/DD/]uuid.vcon
"""
if created_at:
timestamp = datetime.fromisoformat(created_at).strftime("%Y/%m/%d")
key = f"{timestamp}/{vcon_uuid}.vcon"
else:
key = f"{vcon_uuid}.vcon"
if not s3_path:
return key
return f"{s3_path.rstrip('/')}/{key}"
Expand All @@ -53,7 +68,7 @@ def save(
vcon = vcon_redis.get_vcon(vcon_uuid)
s3 = _create_s3_client(opts)

destination_directory = _build_s3_key(vcon_uuid, opts.get("s3_path"))
destination_directory = _build_s3_key(vcon_uuid, vcon.created_at, opts.get("s3_path"))
s3.put_object(
Bucket=opts["aws_bucket"], Key=destination_directory, Body=vcon.dumps()
)
Expand All @@ -69,7 +84,7 @@ def get(vcon_uuid: str, opts=default_options) -> Optional[dict]:
try:
s3 = _create_s3_client(opts)

key = _build_s3_key(vcon_uuid, opts.get("s3_path"))
key = _build_s3_key(vcon_uuid, s3_path=opts.get("s3_path"))

response = s3.get_object(Bucket=opts["aws_bucket"], Key=key)
return json.loads(response['Body'].read().decode('utf-8'))
Expand Down
42 changes: 29 additions & 13 deletions tests/storage/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,36 +186,51 @@ def test_create_client_with_various_aws_regions(self):
class TestBuildS3Key:
"""Tests for the _build_s3_key helper function."""

def test_build_key_without_prefix(self):
"""Test key building without s3_path prefix."""
def test_build_key_without_prefix_or_date(self):
"""Test key building without s3_path prefix or created_at."""
key = _build_s3_key("test-uuid")
assert key == "test-uuid.vcon"

def test_build_key_with_prefix(self):
"""Test key building with s3_path prefix."""
key = _build_s3_key("test-uuid", "vcons")
def test_build_key_with_prefix_only(self):
"""Test key building with s3_path prefix but no created_at."""
key = _build_s3_key("test-uuid", s3_path="vcons")
assert key == "vcons/test-uuid.vcon"

def test_build_key_with_trailing_slash_prefix(self):
"""Test key building with trailing slash in prefix."""
key = _build_s3_key("test-uuid", "vcons/")
key = _build_s3_key("test-uuid", s3_path="vcons/")
assert key == "vcons/test-uuid.vcon"

def test_build_key_with_none_prefix(self):
"""Test key building with None prefix."""
key = _build_s3_key("test-uuid", None)
key = _build_s3_key("test-uuid", s3_path=None)
assert key == "test-uuid.vcon"

def test_build_key_with_empty_prefix(self):
"""Test key building with empty string prefix."""
key = _build_s3_key("test-uuid", "")
key = _build_s3_key("test-uuid", s3_path="")
assert key == "test-uuid.vcon"

def test_build_key_with_nested_prefix(self):
"""Test key building with nested prefix."""
key = _build_s3_key("test-uuid", "data/vcons/archive")
key = _build_s3_key("test-uuid", s3_path="data/vcons/archive")
assert key == "data/vcons/archive/test-uuid.vcon"

def test_build_key_with_created_at(self):
"""Test key building with created_at generates date folder."""
key = _build_s3_key("test-uuid", created_at="2025-12-10T15:30:00Z")
assert key == "2025/12/10/test-uuid.vcon"

def test_build_key_with_created_at_and_prefix(self):
"""Test key building with both created_at and s3_path."""
key = _build_s3_key("test-uuid", created_at="2025-12-10T15:30:00Z", s3_path="vcons")
assert key == "vcons/2025/12/10/test-uuid.vcon"

def test_build_key_with_created_at_and_nested_prefix(self):
"""Test key building with created_at and nested prefix."""
key = _build_s3_key("test-uuid", created_at="2024-01-15T08:00:00Z", s3_path="data/archive")
assert key == "data/archive/2024/01/15/test-uuid.vcon"


class TestSave:
"""Tests for the save function."""
Expand All @@ -225,6 +240,7 @@ def mock_vcon(self):
"""Create a mock vCon object."""
mock = MagicMock()
mock.dumps.return_value = '{"uuid": "test-uuid", "vcon": "1.0.0"}'
mock.created_at = "2025-12-10T15:30:00Z"
return mock

@pytest.fixture
Expand All @@ -237,7 +253,7 @@ def base_opts(self):
}

def test_save_basic(self, mock_vcon, base_opts):
"""Test basic save operation."""
"""Test basic save operation with date folder."""
with patch("server.storage.s3.VconRedis") as mock_redis_class, \
patch("server.storage.s3.boto3.client") as mock_boto_client:

Expand All @@ -255,10 +271,10 @@ def test_save_basic(self, mock_vcon, base_opts):

call_args = mock_s3.put_object.call_args
assert call_args.kwargs["Bucket"] == "test-bucket"
assert call_args.kwargs["Key"] == "test-uuid.vcon"
assert call_args.kwargs["Key"] == "2025/12/10/test-uuid.vcon"

def test_save_with_s3_path_prefix(self, mock_vcon, base_opts):
"""Test save operation with s3_path prefix."""
"""Test save operation with s3_path prefix and date folder."""
base_opts["s3_path"] = "vcons"

with patch("server.storage.s3.VconRedis") as mock_redis_class, \
Expand All @@ -274,7 +290,7 @@ def test_save_with_s3_path_prefix(self, mock_vcon, base_opts):
save("test-uuid", base_opts)

call_args = mock_s3.put_object.call_args
assert call_args.kwargs["Key"] == "vcons/test-uuid.vcon"
assert call_args.kwargs["Key"] == "vcons/2025/12/10/test-uuid.vcon"

def test_save_with_region(self, mock_vcon, base_opts):
"""Test save operation with region specified."""
Expand Down